From b2976aed5b7330eec4f6d17ccb5c992ef49eafbc Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 15 Jul 2026 14:54:51 -0400 Subject: [PATCH 01/12] feat: support multiple test repos and per-game dependency sets in profiles --- src/commands/handlers.rs | 18 +++++++----- src/commands/mono/resolve.rs | 2 +- src/profile/display.rs | 14 +++++---- src/profile/types.rs | 14 +++++---- src/resolve/resolver.rs | 9 +++++- src/run.rs | 2 +- tests/commands/mono/resolve.rs | 14 +++++---- tests/profile/crud.rs | 54 +++++++++++++++++----------------- tests/profile/display.rs | 5 ++-- tests/profile/types.rs | 6 ++-- tests/resolve/resolve.rs | 10 ++++--- 11 files changed, 86 insertions(+), 62 deletions(-) diff --git a/src/commands/handlers.rs b/src/commands/handlers.rs index 21711b8..d3cc465 100644 --- a/src/commands/handlers.rs +++ b/src/commands/handlers.rs @@ -8,7 +8,7 @@ use crate::{ profile::{add_profile, list_profiles, remove_profile, Profile}, workspace::resolve_workspace, }; -use std::{error::Error, path::PathBuf}; +use std::{collections::HashMap, error::Error, path::PathBuf}; /// Handles configuration-related subcommands. /// # Errors @@ -52,12 +52,16 @@ pub fn handle_profile_cmd( match action { ProfileAction::List => list_profiles(config, io), ProfileAction::Remove { name } => remove_profile(config, &name, yes, io, flags)?, - ProfileAction::Add { - name, - test_repo, - repos, - } => { - let profile = Profile::from_args(test_repo, repos)?; + ProfileAction::Add { name, test_repo, repos } => { + let test_repos = test_repo + .map(|r| HashMap::from([("default".to_string(), r)])) + .unwrap_or_default(); + let deps = if repos.is_empty() { + HashMap::new() + } else { + HashMap::from([("default".to_string(), repos)]) + }; + let profile = Profile::from_args(test_repos, deps)?; add_profile(config, &name, &profile, yes, io, flags)?; } } diff --git a/src/commands/mono/resolve.rs b/src/commands/mono/resolve.rs index c827db9..3ad96d8 100644 --- a/src/commands/mono/resolve.rs +++ b/src/commands/mono/resolve.rs @@ -48,7 +48,7 @@ pub fn resolve_test_repo_for_mono(args: &ResolvedArgs) -> Result #[must_use] pub fn resolve_repos_for_mono(args: &ResolvedArgs, profile: Option<&Profile>) -> Vec { profile - .map(|p| p.deps.clone()) + .map(|p| p.deps.values().flatten().cloned().collect()) .or_else(|| args.mono.repos.clone()) .unwrap_or_default() } diff --git a/src/profile/display.rs b/src/profile/display.rs index b2a3bc0..0fb2646 100644 --- a/src/profile/display.rs +++ b/src/profile/display.rs @@ -7,15 +7,17 @@ pub fn print_profile_details( label: &str, profile: &Profile, ) { - let Profile { test_repo, deps } = profile; + let Profile { test_repos, deps } = profile; writeln!(output, " {title}").ok(); - if let Some(t) = test_repo { - writeln!(output, " {t}").ok(); + for (key, repo) in test_repos { + writeln!(output, " {key}: {repo}").ok(); } if !deps.is_empty() { - writeln!(output, " {label}: {}", deps.len()).ok(); - for d in deps { - writeln!(output, " - {d}").ok(); + writeln!(output, " {label}: {}", deps.values().map(Vec::len).sum::()).ok(); + for (key, repos) in deps { + for d in repos { + writeln!(output, " - [{key}] {d}").ok(); + } } } } diff --git a/src/profile/types.rs b/src/profile/types.rs index 9a0b47c..b45190e 100644 --- a/src/profile/types.rs +++ b/src/profile/types.rs @@ -1,18 +1,22 @@ +use std::collections::HashMap; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Default, Clone, Debug)] pub struct Profile { - pub test_repo: Option, - pub deps: Vec, + pub test_repos: HashMap, + pub deps: HashMap>, } impl Profile { /// # Errors /// Returns an error if neither a test repo nor any dependency is given. - pub fn from_args(test_repo: Option, deps: Vec) -> Result { - if test_repo.is_none() && deps.is_empty() { + pub fn from_args( + test_repos: HashMap, + deps: HashMap> + ) -> Result { + if test_repos.is_empty() && deps.is_empty() { return Err("profile requires --test-repo or at least one dependency repo".to_string()); } - Ok(Self { test_repo, deps }) + Ok(Self { test_repos, deps }) } } diff --git a/src/resolve/resolver.rs b/src/resolve/resolver.rs index 2befc2a..a4e476c 100644 --- a/src/resolve/resolver.rs +++ b/src/resolve/resolver.rs @@ -131,7 +131,14 @@ fn resolve_repo( })?), None => None, }; - Ok(repo.or_else(|| prof.and_then(|p| p.test_repo.clone()))) + Ok(repo.or_else(|| { + prof.and_then(|p| { + p.test_repos + .get("default") + .or_else(|| p.test_repos.values().next()) + .cloned() + }) + })) } /// Resolves raw `Args` into `ResolvedArgs` by applying config defaults and CLI overrides. diff --git a/src/run.rs b/src/run.rs index eddd9f5..8166b72 100644 --- a/src/run.rs +++ b/src/run.rs @@ -80,7 +80,7 @@ fn execute( config .profiles .get(p) - .is_some_and(|p| p.test_repo.is_some()) + .is_some_and(|p| !p.test_repos.is_empty()) }); if !has_repo { diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs index b476619..41080c9 100644 --- a/tests/commands/mono/resolve.rs +++ b/tests/commands/mono/resolve.rs @@ -6,8 +6,7 @@ use star_setup::{ profile::Profile, }; use std::{ - fs::{create_dir_all, read_to_string, write}, - slice::from_ref, + collections::HashMap, fs::{create_dir_all, read_to_string, write}, slice::from_ref, }; /* ===== RESOLVE_TEST_REPO ===== */ @@ -69,8 +68,8 @@ fn test_resolve_test_repo_for_mono_prefers_positional_over_profile() { config.profiles.insert( "myprofile".to_string(), Profile { - test_repo: Some("user/other".to_string()), - deps: vec![], + test_repos: HashMap::from([("default".to_string(), "user/other".to_string())]), + deps: HashMap::from([("default".to_string(), vec![])]) }, ); let args = default_resolved(); @@ -83,8 +82,11 @@ fn test_resolve_test_repo_for_mono_prefers_positional_over_profile() { #[test] fn test_resolve_repos_for_mono_with_profile() { let profile = Profile { - test_repo: None, - deps: vec!["user/lib1".to_string(), "user/lib2".to_string()], + test_repos: HashMap::new(), + deps: HashMap::from([( + "default".to_string(), + vec!["user/lib1".to_string(), "user/lib2".to_string()], + )]), }; let args = default_resolved(); assert_eq!( diff --git a/tests/profile/crud.rs b/tests/profile/crud.rs index 88b44a8..18ebcce 100644 --- a/tests/profile/crud.rs +++ b/tests/profile/crud.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::common::{make_flags, with_io_dir, with_io_input_output, with_io_output}; use star_setup::{ config::{load_config, save_config, Config}, @@ -25,8 +27,8 @@ fn test_add_profile_inserts_and_saves() { &mut config, "myprofile", &Profile { - test_repo: None, - deps: vec!["user/repo1".to_string()], + test_repos: HashMap::new(), + deps: HashMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), }, true, io, @@ -51,8 +53,8 @@ fn test_save_and_load_profile_roundtrip() { &mut config, "myprofile", Profile { - test_repo: None, - deps: vec!["user/repo1".to_string(), "user/repo2".to_string()], + test_repos: HashMap::new(), + deps: HashMap::from([("default".to_string(), vec!["user/repo1".to_string(), "user/repo2".to_string()])]), }, ); @@ -61,7 +63,7 @@ fn test_save_and_load_profile_roundtrip() { let loaded = load_config(&[path], false, false, &mut io.output); assert!(loaded.profiles.contains_key("myprofile")); assert_eq!( - loaded.profiles["myprofile"].deps, + loaded.profiles["myprofile"].deps["default"], vec!["user/repo1", "user/repo2"] ); }); @@ -76,8 +78,8 @@ fn test_insert_profile() { &mut config, "myprofile", Profile { - test_repo: None, - deps: vec!["user/repo1".to_string()], + test_repos: HashMap::new(), + deps: HashMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), }, ); @@ -97,9 +99,7 @@ fn test_remove_profile_entry_exists() { #[test] fn test_has_profile_true() { let mut config = Config::new(); - insert_profile(&mut config, "myprofile", Profile::default()); - assert!(has_profile(&config, "myprofile")); } @@ -114,23 +114,23 @@ fn test_add_profile_overwrites_existing() { &mut config, "myprofile", Profile { - test_repo: None, - deps: vec!["old/repo".to_string()], + test_repos: HashMap::new(), + deps: HashMap::from([("default".to_string(), vec!["old/repo".to_string()])]), }, ); add_profile( &mut config, "myprofile", &Profile { - test_repo: None, - deps: vec!["new/repo".to_string()], + test_repos: HashMap::new(), + deps: HashMap::from([("default".to_string(), vec!["new/repo".to_string()])]), }, true, io, make_flags(), ) .unwrap(); - assert_eq!(config.profiles["myprofile"].deps, vec!["new/repo"]); + assert_eq!( config.profiles["myprofile"].deps["default"], vec!["new/repo"]); }); } @@ -144,12 +144,12 @@ fn test_add_profile_multiple_repos() { &mut config, "myprofile", &Profile { - test_repo: None, - deps: vec![ + test_repos: HashMap::new(), + deps: HashMap::from([("default".to_string(), vec![ "user/repo1".to_string(), "user/repo2".to_string(), "user/repo3".to_string(), - ], + ])]), }, true, io, @@ -157,7 +157,7 @@ fn test_add_profile_multiple_repos() { ) .unwrap(); - assert_eq!(config.profiles["myprofile"].deps.len(), 3); + assert_eq!(config.profiles["myprofile"].deps["default"].len(), 3); }); } @@ -172,23 +172,23 @@ fn test_add_profile_aborts_when_exists_and_not_confirmed() { &mut config, "myprofile", Profile { - test_repo: None, - deps: vec!["old/repo".to_string()], + test_repos: HashMap::new(), + deps: HashMap::from([("default".to_string(), vec!["old/repo".to_string()])]), }, ); add_profile( &mut config, "myprofile", &Profile { - test_repo: None, - deps: vec!["new/repo".to_string()], + test_repos: HashMap::new(), + deps: HashMap::from([("default".to_string(), vec!["new/repo".to_string()])]), }, false, io, make_flags(), ) .unwrap(); - assert_eq!(config.profiles["myprofile"].deps, vec!["old/repo"]); + assert_eq!(config.profiles["myprofile"].deps["default"], vec!["old/repo"]); }); } @@ -211,8 +211,8 @@ fn test_remove_profile_removes_and_saves() { &mut config, "myprofile", &Profile { - test_repo: None, - deps: vec!["user/repo1".to_string()], + test_repos: HashMap::new(), + deps: HashMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), }, true, io, @@ -245,8 +245,8 @@ fn test_remove_profile_aborts_when_not_confirmed() { &mut config, "myprofile", &Profile { - test_repo: None, - deps: vec!["user/repo1".to_string()], + test_repos: HashMap::new(), + deps: HashMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), }, true, io, diff --git a/tests/profile/display.rs b/tests/profile/display.rs index 8e1bda3..00b58b5 100644 --- a/tests/profile/display.rs +++ b/tests/profile/display.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use crate::common::with_io_output; use star_setup::{ config::Config, @@ -21,8 +22,8 @@ fn test_list_profiles_with_entries() { &mut config, "myprofile", Profile { - test_repo: None, - deps: vec!["user/repo1".to_string()], + test_repos: HashMap::new(), + deps: HashMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), }, ); list_profiles(&config, io); diff --git a/tests/profile/types.rs b/tests/profile/types.rs index cb67eef..4a74d35 100644 --- a/tests/profile/types.rs +++ b/tests/profile/types.rs @@ -1,11 +1,13 @@ use star_setup::profile::Profile; +use std::collections::HashMap; #[test] fn test_profile_from_args_errors_when_empty() { - assert!(Profile::from_args(None, vec![]).is_err()); + assert!(Profile::from_args(HashMap::new(), HashMap::new()).is_err()); } #[test] fn test_profile_from_args_test_repo_only() { - assert!(Profile::from_args(Some("user/app".to_string()), vec![]).is_ok()); + let test_repos = HashMap::from([("default".to_string(), "user/app".to_string())]); + assert!(Profile::from_args(test_repos, HashMap::new()).is_ok()); } diff --git a/tests/resolve/resolve.rs b/tests/resolve/resolve.rs index 5a95e4f..7324bca 100644 --- a/tests/resolve/resolve.rs +++ b/tests/resolve/resolve.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::common::default_args; use star_setup::{ build::BuildType, @@ -296,8 +298,8 @@ fn test_resolve_fills_repo_from_profile_test_repo() { config.profiles.insert( "p".to_string(), star_setup::profile::Profile { - test_repo: Some("user/app".to_string()), - deps: vec![], + test_repos: HashMap::from([("default".to_string(), "user/app".to_string())]), + deps: HashMap::new(), }, ); let mut args = default_args(); @@ -312,8 +314,8 @@ fn test_resolve_positional_beats_profile_test_repo() { config.profiles.insert( "p".to_string(), star_setup::profile::Profile { - test_repo: Some("user/other".to_string()), - deps: vec![], + test_repos: HashMap::from([("default".to_string(), "user/other".to_string())]), + deps: HashMap::new(), }, ); let mut args = default_args(); From f538552af073f5bfb82de5e6a014716a726b2a77 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 15 Jul 2026 15:28:44 -0400 Subject: [PATCH 02/12] refactor: launch dev server in a dedicated terminal window --- src/build/npm/dev.rs | 39 +++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/build/npm/dev.rs b/src/build/npm/dev.rs index 99794ed..c83c40f 100644 --- a/src/build/npm/dev.rs +++ b/src/build/npm/dev.rs @@ -41,9 +41,9 @@ fn is_vercel_project(repo_path: &Path, json: &Value) -> bool { .any(|k| json.get(k).and_then(|d| d.get("@vercel/node")).is_some()) } -/// Runs the dev server in the foreground, blocking until it exits (Ctrl-C). +/// Opens the dev server in a new terminal. /// # Errors -/// Returns an error only if the process fails to spawn. +/// Returns an error if the terminal cannot be opened. pub fn open_dev_server( repo_path: &Path, cmd: &str, @@ -56,8 +56,7 @@ pub fn open_dev_server( } writeln!(io.output, "Starting dev server: {cmd}").ok(); - writeln!( - io.output, + writeln!(io.output, " in {} (press Ctrl-C to stop)", repo_path.display() ) @@ -65,8 +64,19 @@ pub fn open_dev_server( #[cfg(target_os = "windows")] let mut command = { + let cmd_esc = cmd.replace('\'', "''"); + let dir_esc = repo_path.display().to_string().replace('\'', "''"); let mut c = Command::new("powershell"); - c.args(["-NoProfile", "-Command", cmd]); + c.args([ + "-NoProfile", + "-Command", + "Start-Process", + "powershell", + "-ArgumentList", + &format!("'-NoExit','-Command','{cmd_esc}'"), + "-WorkingDirectory", + &format!("'{dir_esc}'"), + ]); c }; #[cfg(not(target_os = "windows"))] @@ -75,22 +85,19 @@ pub fn open_dev_server( let resolved = resolve_exe_args(&parts); let mut c = Command::new(resolved[0]); c.args(&resolved[1..]); + c.current_dir(repo_path); c }; command - .current_dir(repo_path) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); - match command.spawn() { - Err(e) => Err(format!("Failed to start dev server: {e}")), - Ok(mut child) => { - child.wait().ok(); - Ok(()) - } - } + command + .spawn() + .map(|_| ()) + .map_err(|e| format!("Failed to start dev server: {e}")) } /// Opens the project's dev server if `--dev` was passed and the build system is npm. From 23351d300e913d8f360d5c92b87e033453a9fa41 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 15 Jul 2026 20:51:06 -0400 Subject: [PATCH 03/12] refactor: HashMap to BTreeMap for ordering consistency --- src/commands/handlers.rs | 8 +++---- src/profile/types.rs | 10 ++++---- tests/commands/mono/resolve.rs | 10 ++++---- tests/profile/crud.rs | 43 +++++++++++++++++----------------- tests/profile/display.rs | 6 ++--- tests/profile/types.rs | 8 +++---- tests/resolve/resolve.rs | 11 ++++----- 7 files changed, 47 insertions(+), 49 deletions(-) diff --git a/src/commands/handlers.rs b/src/commands/handlers.rs index d3cc465..05dfe9d 100644 --- a/src/commands/handlers.rs +++ b/src/commands/handlers.rs @@ -8,7 +8,7 @@ use crate::{ profile::{add_profile, list_profiles, remove_profile, Profile}, workspace::resolve_workspace, }; -use std::{collections::HashMap, error::Error, path::PathBuf}; +use std::{collections::BTreeMap, error::Error, path::PathBuf}; /// Handles configuration-related subcommands. /// # Errors @@ -54,12 +54,12 @@ pub fn handle_profile_cmd( ProfileAction::Remove { name } => remove_profile(config, &name, yes, io, flags)?, ProfileAction::Add { name, test_repo, repos } => { let test_repos = test_repo - .map(|r| HashMap::from([("default".to_string(), r)])) + .map(|r| BTreeMap::from([("default".to_string(), r)])) .unwrap_or_default(); let deps = if repos.is_empty() { - HashMap::new() + BTreeMap::new() } else { - HashMap::from([("default".to_string(), repos)]) + BTreeMap::from([("default".to_string(), repos)]) }; let profile = Profile::from_args(test_repos, deps)?; add_profile(config, &name, &profile, yes, io, flags)?; diff --git a/src/profile/types.rs b/src/profile/types.rs index b45190e..24f0db4 100644 --- a/src/profile/types.rs +++ b/src/profile/types.rs @@ -1,18 +1,18 @@ -use std::collections::HashMap; +use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Default, Clone, Debug)] pub struct Profile { - pub test_repos: HashMap, - pub deps: HashMap>, + pub test_repos: BTreeMap, + pub deps: BTreeMap>, } impl Profile { /// # Errors /// Returns an error if neither a test repo nor any dependency is given. pub fn from_args( - test_repos: HashMap, - deps: HashMap> + test_repos: BTreeMap, + deps: BTreeMap> ) -> Result { if test_repos.is_empty() && deps.is_empty() { return Err("profile requires --test-repo or at least one dependency repo".to_string()); diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs index 41080c9..3cba621 100644 --- a/tests/commands/mono/resolve.rs +++ b/tests/commands/mono/resolve.rs @@ -6,7 +6,7 @@ use star_setup::{ profile::Profile, }; use std::{ - collections::HashMap, fs::{create_dir_all, read_to_string, write}, slice::from_ref, + collections::BTreeMap, fs::{create_dir_all, read_to_string, write}, slice::from_ref, }; /* ===== RESOLVE_TEST_REPO ===== */ @@ -68,8 +68,8 @@ fn test_resolve_test_repo_for_mono_prefers_positional_over_profile() { config.profiles.insert( "myprofile".to_string(), Profile { - test_repos: HashMap::from([("default".to_string(), "user/other".to_string())]), - deps: HashMap::from([("default".to_string(), vec![])]) + test_repos: BTreeMap::from([("default".to_string(), "user/other".to_string())]), + deps: BTreeMap::from([("default".to_string(), vec![])]) }, ); let args = default_resolved(); @@ -82,8 +82,8 @@ fn test_resolve_test_repo_for_mono_prefers_positional_over_profile() { #[test] fn test_resolve_repos_for_mono_with_profile() { let profile = Profile { - test_repos: HashMap::new(), - deps: HashMap::from([( + test_repos: BTreeMap::new(), + deps: BTreeMap::from([( "default".to_string(), vec!["user/lib1".to_string(), "user/lib2".to_string()], )]), diff --git a/tests/profile/crud.rs b/tests/profile/crud.rs index 18ebcce..cd443b6 100644 --- a/tests/profile/crud.rs +++ b/tests/profile/crud.rs @@ -1,5 +1,4 @@ -use std::collections::HashMap; - +use std::collections::BTreeMap; use crate::common::{make_flags, with_io_dir, with_io_input_output, with_io_output}; use star_setup::{ config::{load_config, save_config, Config}, @@ -27,8 +26,8 @@ fn test_add_profile_inserts_and_saves() { &mut config, "myprofile", &Profile { - test_repos: HashMap::new(), - deps: HashMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), + test_repos: BTreeMap::new(), + deps: BTreeMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), }, true, io, @@ -53,8 +52,8 @@ fn test_save_and_load_profile_roundtrip() { &mut config, "myprofile", Profile { - test_repos: HashMap::new(), - deps: HashMap::from([("default".to_string(), vec!["user/repo1".to_string(), "user/repo2".to_string()])]), + test_repos: BTreeMap::new(), + deps: BTreeMap::from([("default".to_string(), vec!["user/repo1".to_string(), "user/repo2".to_string()])]), }, ); @@ -78,8 +77,8 @@ fn test_insert_profile() { &mut config, "myprofile", Profile { - test_repos: HashMap::new(), - deps: HashMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), + test_repos: BTreeMap::new(), + deps: BTreeMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), }, ); @@ -114,16 +113,16 @@ fn test_add_profile_overwrites_existing() { &mut config, "myprofile", Profile { - test_repos: HashMap::new(), - deps: HashMap::from([("default".to_string(), vec!["old/repo".to_string()])]), + test_repos: BTreeMap::new(), + deps: BTreeMap::from([("default".to_string(), vec!["old/repo".to_string()])]), }, ); add_profile( &mut config, "myprofile", &Profile { - test_repos: HashMap::new(), - deps: HashMap::from([("default".to_string(), vec!["new/repo".to_string()])]), + test_repos: BTreeMap::new(), + deps: BTreeMap::from([("default".to_string(), vec!["new/repo".to_string()])]), }, true, io, @@ -144,8 +143,8 @@ fn test_add_profile_multiple_repos() { &mut config, "myprofile", &Profile { - test_repos: HashMap::new(), - deps: HashMap::from([("default".to_string(), vec![ + test_repos: BTreeMap::new(), + deps: BTreeMap::from([("default".to_string(), vec![ "user/repo1".to_string(), "user/repo2".to_string(), "user/repo3".to_string(), @@ -172,16 +171,16 @@ fn test_add_profile_aborts_when_exists_and_not_confirmed() { &mut config, "myprofile", Profile { - test_repos: HashMap::new(), - deps: HashMap::from([("default".to_string(), vec!["old/repo".to_string()])]), + test_repos: BTreeMap::new(), + deps: BTreeMap::from([("default".to_string(), vec!["old/repo".to_string()])]), }, ); add_profile( &mut config, "myprofile", &Profile { - test_repos: HashMap::new(), - deps: HashMap::from([("default".to_string(), vec!["new/repo".to_string()])]), + test_repos: BTreeMap::new(), + deps: BTreeMap::from([("default".to_string(), vec!["new/repo".to_string()])]), }, false, io, @@ -211,8 +210,8 @@ fn test_remove_profile_removes_and_saves() { &mut config, "myprofile", &Profile { - test_repos: HashMap::new(), - deps: HashMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), + test_repos: BTreeMap::new(), + deps: BTreeMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), }, true, io, @@ -245,8 +244,8 @@ fn test_remove_profile_aborts_when_not_confirmed() { &mut config, "myprofile", &Profile { - test_repos: HashMap::new(), - deps: HashMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), + test_repos: BTreeMap::new(), + deps: BTreeMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), }, true, io, diff --git a/tests/profile/display.rs b/tests/profile/display.rs index 00b58b5..458510e 100644 --- a/tests/profile/display.rs +++ b/tests/profile/display.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::BTreeMap; use crate::common::with_io_output; use star_setup::{ config::Config, @@ -22,8 +22,8 @@ fn test_list_profiles_with_entries() { &mut config, "myprofile", Profile { - test_repos: HashMap::new(), - deps: HashMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), + test_repos: BTreeMap::new(), + deps: BTreeMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), }, ); list_profiles(&config, io); diff --git a/tests/profile/types.rs b/tests/profile/types.rs index 4a74d35..50e31f4 100644 --- a/tests/profile/types.rs +++ b/tests/profile/types.rs @@ -1,13 +1,13 @@ use star_setup::profile::Profile; -use std::collections::HashMap; +use std::collections::BTreeMap; #[test] fn test_profile_from_args_errors_when_empty() { - assert!(Profile::from_args(HashMap::new(), HashMap::new()).is_err()); + assert!(Profile::from_args(BTreeMap::new(), BTreeMap::new()).is_err()); } #[test] fn test_profile_from_args_test_repo_only() { - let test_repos = HashMap::from([("default".to_string(), "user/app".to_string())]); - assert!(Profile::from_args(test_repos, HashMap::new()).is_ok()); + let test_repos = BTreeMap::from([("default".to_string(), "user/app".to_string())]); + assert!(Profile::from_args(test_repos, BTreeMap::new()).is_ok()); } diff --git a/tests/resolve/resolve.rs b/tests/resolve/resolve.rs index 7324bca..393ad7a 100644 --- a/tests/resolve/resolve.rs +++ b/tests/resolve/resolve.rs @@ -1,5 +1,4 @@ -use std::collections::HashMap; - +use std::collections::BTreeMap; use crate::common::default_args; use star_setup::{ build::BuildType, @@ -298,8 +297,8 @@ fn test_resolve_fills_repo_from_profile_test_repo() { config.profiles.insert( "p".to_string(), star_setup::profile::Profile { - test_repos: HashMap::from([("default".to_string(), "user/app".to_string())]), - deps: HashMap::new(), + test_repos: BTreeMap::from([("default".to_string(), "user/app".to_string())]), + deps: BTreeMap::new(), }, ); let mut args = default_args(); @@ -314,8 +313,8 @@ fn test_resolve_positional_beats_profile_test_repo() { config.profiles.insert( "p".to_string(), star_setup::profile::Profile { - test_repos: HashMap::from([("default".to_string(), "user/other".to_string())]), - deps: HashMap::new(), + test_repos: BTreeMap::from([("default".to_string(), "user/other".to_string())]), + deps: BTreeMap::new(), }, ); let mut args = default_args(); From 60b196ee2d7d066aacffb5e0287f80c0d9da89b9 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Wed, 15 Jul 2026 21:11:18 -0400 Subject: [PATCH 04/12] refactor: make repo and --profile mutually exclusive --- src/cli/args.rs | 1 + tests/commands/mono/resolve.rs | 17 ----------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/cli/args.rs b/src/cli/args.rs index 2e3616f..b2c2a97 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -28,6 +28,7 @@ pub enum Command { )] pub struct Args { /// Repository name (username/repo) or full GitHub URL + #[arg(conflicts_with = "profile")] pub repo: Option, /// Select a named configuration to use diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs index 3cba621..0ec9533 100644 --- a/tests/commands/mono/resolve.rs +++ b/tests/commands/mono/resolve.rs @@ -62,23 +62,6 @@ fn test_resolve_test_repo_for_mono_errors_when_profile_empty() { assert!(resolve_test_repo_for_mono(&args).is_err()); } -#[test] -fn test_resolve_test_repo_for_mono_prefers_positional_over_profile() { - let mut config = Config::new(); - config.profiles.insert( - "myprofile".to_string(), - Profile { - test_repos: BTreeMap::from([("default".to_string(), "user/other".to_string())]), - deps: BTreeMap::from([("default".to_string(), vec![])]) - }, - ); - let args = default_resolved(); - assert_eq!( - resolve_test_repo_for_mono(&args), - Ok("user/repo".to_string()) - ); -} - #[test] fn test_resolve_repos_for_mono_with_profile() { let profile = Profile { From 3d59487b1052b709a1f28ac3359c2e61ea37f824 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Thu, 16 Jul 2026 11:32:20 -0400 Subject: [PATCH 05/12] feat: run all games in a profile's test repos simultaneously --- src/build/npm/dev.rs | 3 +- src/build/npm/watch.rs | 8 ++-- src/commands/handlers.rs | 6 ++- src/commands/header.rs | 21 ++++++++-- src/commands/mod.rs | 3 +- src/commands/mono/display.rs | 73 ++++++++++++++++------------------ src/commands/mono/mod.rs | 4 +- src/commands/mono/mode.rs | 32 +++++++++------ src/commands/mono/resolve.rs | 18 +++++++-- src/commands/mono/setup.rs | 10 +++-- src/commands/mono/types.rs | 11 +++++ src/commands/single.rs | 5 ++- src/profile/display.rs | 7 +++- src/profile/types.rs | 4 +- tests/build/npm/watch.rs | 40 +++++++++---------- tests/commands/display.rs | 8 ++-- tests/commands/header.rs | 46 ++++++++++++++++++++- tests/commands/mono/mode.rs | 34 +++++++++++++++- tests/commands/mono/resolve.rs | 33 +++++++-------- tests/commands/mono/setup.rs | 21 +++++++--- tests/profile/crud.rs | 36 +++++++++++------ tests/profile/display.rs | 2 +- tests/resolve/resolve.rs | 2 +- 23 files changed, 290 insertions(+), 137 deletions(-) create mode 100644 src/commands/mono/types.rs diff --git a/src/build/npm/dev.rs b/src/build/npm/dev.rs index c83c40f..b6e71c8 100644 --- a/src/build/npm/dev.rs +++ b/src/build/npm/dev.rs @@ -56,7 +56,8 @@ pub fn open_dev_server( } writeln!(io.output, "Starting dev server: {cmd}").ok(); - writeln!(io.output, + writeln!( + io.output, " in {} (press Ctrl-C to stop)", repo_path.display() ) diff --git a/src/build/npm/watch.rs b/src/build/npm/watch.rs index 63bb6ac..bedee8b 100644 --- a/src/build/npm/watch.rs +++ b/src/build/npm/watch.rs @@ -20,7 +20,9 @@ fn get_watch_command( if scripts.get("watch").is_some() { Some(format!("npm --workspace=repos/{dir} run watch")) } else if scripts.get("build").is_some() { - Some(format!("npm --workspace=repos/{dir} run build ''--'' --watch")) + Some(format!( + "npm --workspace=repos/{dir} run build ''--'' --watch" + )) } else { if flags.verbose { writeln!( @@ -39,11 +41,11 @@ fn get_watch_command( pub fn generate_watch_scripts( mono_dir: &Path, repos_path: &Path, - repos: &[String], + deps: &[String], io: &mut IoCtx<'_>, flags: RunFlags, ) -> Result { - let lib_dirs: Vec = repos.iter().skip(1).map(|r| repo_dir_name(r)).collect(); + let lib_dirs: Vec = deps.iter().map(|r| repo_dir_name(r)).collect(); if lib_dirs.is_empty() { return Ok(false); diff --git a/src/commands/handlers.rs b/src/commands/handlers.rs index 05dfe9d..8d172ce 100644 --- a/src/commands/handlers.rs +++ b/src/commands/handlers.rs @@ -52,7 +52,11 @@ pub fn handle_profile_cmd( match action { ProfileAction::List => list_profiles(config, io), ProfileAction::Remove { name } => remove_profile(config, &name, yes, io, flags)?, - ProfileAction::Add { name, test_repo, repos } => { + ProfileAction::Add { + name, + test_repo, + repos, + } => { let test_repos = test_repo .map(|r| BTreeMap::from([("default".to_string(), r)])) .unwrap_or_default(); diff --git a/src/commands/header.rs b/src/commands/header.rs index 624b2ed..f486294 100644 --- a/src/commands/header.rs +++ b/src/commands/header.rs @@ -3,13 +3,14 @@ use crate::ctx::IoCtx; /// Header information printed at the start of each command mode. pub struct ModeHeader<'a> { pub mode: &'a str, - pub test_repo: Option<&'a str>, + pub test_repos: &'a [String], pub repo_name: Option<&'a str>, - pub use_ssh: bool, pub mono_dir: Option<&'a str>, pub profile: Option<&'a str>, pub lib_count: Option, pub repo_count: Option, + pub use_ssh: bool, + pub verbose: bool, } /// Prints a formatted header summarizing the current mode and configuration. @@ -18,8 +19,20 @@ pub fn print_mode_header(header: &ModeHeader<'_>, io: &mut IoCtx<'_>) { if let Some(p) = header.profile { writeln!(io.output, " Profile: {p}").ok(); } - if let Some(r) = header.test_repo { - writeln!(io.output, " Test Repository: {r}").ok(); + if !header.test_repos.is_empty() { + if header.verbose { + writeln!(io.output, " Test Repositories:").ok(); + for r in header.test_repos { + writeln!(io.output, " {r}").ok(); + } + } else { + writeln!( + io.output, + " Test Repositories: {}", + header.test_repos.len() + ) + .ok(); + } } else if let Some(r) = header.repo_name { writeln!(io.output, " Repository: {r}").ok(); } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 214c396..fe7a05b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,7 +2,8 @@ pub mod header; pub use header::{print_mode_header, ModeHeader}; pub mod mono; pub use mono::{ - build_repo_list, mono_repo_mode, resolve_repos_for_mono, resolve_setup_paths, resolve_test_repo, + build_repo_list, mono_repo_mode, resolve_dep_repos_for_mono, resolve_setup_paths, + resolve_test_repo, resolve_test_repos_for_mono, }; pub mod single; pub use single::single_repo_mode; diff --git a/src/commands/mono/display.rs b/src/commands/mono/display.rs index b3b1ded..2839bfc 100644 --- a/src/commands/mono/display.rs +++ b/src/commands/mono/display.rs @@ -1,25 +1,11 @@ use crate::{ build::{BuildSystem, BuildSystem::Npm}, + commands::mono::SetupPaths, ctx::{IoCtx, RunFlags}, repository::repo_dir_name, }; use dunce::canonicalize; -use std::{ - collections::HashMap, - hash::BuildHasher, - path::{Path, PathBuf}, - time::Instant, -}; - -/// Resolved display paths for the setup completion summary. -pub struct SetupPaths { - /// Canonicalized path to the mono-repo root directory. - pub mono_repo_disp: PathBuf, - /// Canonicalized path to the test repository executable, if found. - pub exe_path: Option, - /// Canonicalized path to the build output directory, if no canonical map was provided. - pub build_disp: Option, -} +use std::{collections::HashMap, hash::BuildHasher, path::Path, time::Instant}; /// Resolves display paths for setup completion summary. #[must_use] @@ -27,42 +13,47 @@ pub fn resolve_setup_paths( canonical_map: Option<&HashMap>, mono_repo_path: &Path, build_path: &Path, - test_repo: &str, + test_repos: &[String], build_system: Option, ) -> SetupPaths { let mono_repo_disp = canonicalize(mono_repo_path).unwrap_or_else(|_| mono_repo_path.to_path_buf()); - let (exe_path, build_disp) = if let Some(map) = canonical_map { - let test_repo_name = repo_dir_name(test_repo); - let exe_path = map + let (exe_paths, build_disp) = if let Some(map) = canonical_map { + let exe_paths = test_repos .iter() - .find(|(_, v)| *v == &test_repo_name) - .map(|(canonical, _)| { - let exe_name = if cfg!(windows) { - format!("{canonical}.exe") - } else { - canonical.clone() - }; - let p = build_path - .join("repos") - .join(&test_repo_name) - .join(&exe_name); - canonicalize(&p).unwrap_or(p) - }); - (exe_path, None) + .filter_map(|test_repo| { + let test_repo_name = repo_dir_name(test_repo); + map + .iter() + .find(|(_, v)| *v == &test_repo_name) + .map(|(canonical, _)| { + let exe_name = if cfg!(windows) { + format!("{canonical}.exe") + } else { + canonical.clone() + }; + let p = build_path + .join("repos") + .join(&test_repo_name) + .join(&exe_name); + (test_repo_name.clone(), canonicalize(&p).unwrap_or(p)) + }) + }) + .collect(); + (exe_paths, None) } else { let build_disp = if build_system == Some(Npm) { None } else { Some(canonicalize(build_path).unwrap_or_else(|_| build_path.to_path_buf())) }; - (None, build_disp) + (Vec::new(), build_disp) }; SetupPaths { mono_repo_disp, - exe_path, + exe_paths, build_disp, } } @@ -81,8 +72,14 @@ pub fn print_setup_complete( paths.mono_repo_disp.display() ) .ok(); - if let Some(exe) = &paths.exe_path { - writeln!(io.output, " Executable: {}", exe.display()).ok(); + if !paths.exe_paths.is_empty() { + if flags.verbose { + for (name, exe) in &paths.exe_paths { + writeln!(io.output, " Executable [{name}]: {}", exe.display()).ok(); + } + } else { + writeln!(io.output, " Executables: {}", paths.exe_paths.len()).ok(); + } } if let Some(build) = &paths.build_disp { writeln!(io.output, " Build output in: {}", build.display()).ok(); diff --git a/src/commands/mono/mod.rs b/src/commands/mono/mod.rs index f94ace5..78f4adc 100644 --- a/src/commands/mono/mod.rs +++ b/src/commands/mono/mod.rs @@ -1,10 +1,12 @@ +pub mod types; +pub use types::SetupPaths; pub mod display; pub use display::{print_setup_complete, resolve_setup_paths}; pub mod mode; pub use mode::mono_repo_mode; pub mod resolve; pub use resolve::{ - resolve_profile, resolve_repos_for_mono, resolve_test_repo, resolve_test_repo_for_mono, + resolve_dep_repos_for_mono, resolve_profile, resolve_test_repo, resolve_test_repos_for_mono, }; pub mod setup; pub use setup::build_repo_list; diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index f163593..2cf9da9 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -6,11 +6,10 @@ use crate::{ commands::{ build_repo_list, mono::{ - display::{resolve_setup_paths, SetupPaths}, - print_setup_complete, - resolve::{resolve_profile, resolve_test_repo_for_mono}, + print_setup_complete, resolve_profile, resolve_setup_paths, resolve_test_repos_for_mono, + SetupPaths, }, - prepare_build_dir, print_mode_header, resolve_repos_for_mono, ModeHeader, + prepare_build_dir, print_mode_header, resolve_dep_repos_for_mono, ModeHeader, }, config::Config, ctx::RunCtx, @@ -35,9 +34,9 @@ pub fn mono_repo_mode( ) -> Result<(), String> { let total = Instant::now(); let profile = resolve_profile(args, config); - let test_repo = resolve_test_repo_for_mono(args)?; - let deps = resolve_repos_for_mono(args, profile); - let repos = build_repo_list(&test_repo, &deps); + let test_repos = resolve_test_repos_for_mono(args, profile)?; + let deps = resolve_dep_repos_for_mono(args, profile); + let repos = build_repo_list(&test_repos, &deps); print_mode_header( &ModeHeader { @@ -46,13 +45,14 @@ pub fn mono_repo_mode( } else { "Mono-repository" }, - test_repo: Some(&test_repo), + test_repos: &test_repos, repo_name: None, use_ssh: args.connection.ssh, mono_dir: Some(&args.mono.mono_dir), profile: args.mono.profile.as_deref(), lib_count: Some(deps.len()), repo_count: Some(repos.len()), + verbose: ctx.flags.verbose, }, &mut ctx.io, ); @@ -102,7 +102,7 @@ pub fn mono_repo_mode( if build_system == Some(Npm) && !args.build.no_watch - && generate_watch_scripts(&mono_repo_path, &repos_path, &repos, &mut ctx.io, ctx.flags)? + && generate_watch_scripts(&mono_repo_path, &repos_path, &deps, &mut ctx.io, ctx.flags)? && args.build.watch { open_watch_scripts(&mono_repo_path, &mut ctx.io, ctx.flags)?; @@ -111,7 +111,7 @@ pub fn mono_repo_mode( let paths = if ctx.flags.dry_run { SetupPaths { mono_repo_disp: mono_repo_path.clone(), - exe_path: None, + exe_paths: Vec::new(), build_disp: if build_system == Some(Npm) { None } else { @@ -123,7 +123,7 @@ pub fn mono_repo_mode( canonical_map.as_ref(), &mono_repo_path, &build_path, - &test_repo, + &test_repos, build_system, ) }; @@ -139,5 +139,13 @@ pub fn mono_repo_mode( print_setup_complete(&paths, total, &mut ctx.io, ctx.flags); } - maybe_open_dev_server(args, build_system, &repo_dirs[0], ctx) + let test_repo_dirs: Vec = test_repos + .iter() + .map(|r| repos_path.join(repo_dir_name(r))) + .collect(); + + for dir in &test_repo_dirs { + maybe_open_dev_server(args, build_system, dir, ctx)?; + } + Ok(()) } diff --git a/src/commands/mono/resolve.rs b/src/commands/mono/resolve.rs index 3ad96d8..6ef7d57 100644 --- a/src/commands/mono/resolve.rs +++ b/src/commands/mono/resolve.rs @@ -34,19 +34,29 @@ pub fn resolve_profile<'a>(args: &ResolvedArgs, config: &'a Config) -> Option<&' .and_then(|name| config.profiles.get(name)) } -/// Resolves the test repo for mono-repo mode from the already-resolved repo. +/// Resolves all test repositories for mono-repo mode on a profile. /// # Errors /// Returns an error if no repository is available. -pub fn resolve_test_repo_for_mono(args: &ResolvedArgs) -> Result { +pub fn resolve_test_repos_for_mono( + args: &ResolvedArgs, + profile: Option<&Profile>, +) -> Result, String> { + if let Some(p) = profile { + return p + .test_repos + .values() + .map(|r| resolve_test_repo(r.trim_end_matches('/'))) + .collect(); + } match args.repo.as_deref() { - Some(r) => resolve_test_repo(r.trim_end_matches('/')), + Some(r) => resolve_test_repo(r.trim_end_matches('/')).map(|r| vec![r]), None => Err("No repository specified".to_string()), } } /// Resolves the dependency repositories for mono-repo mode from a profile or explicit list. #[must_use] -pub fn resolve_repos_for_mono(args: &ResolvedArgs, profile: Option<&Profile>) -> Vec { +pub fn resolve_dep_repos_for_mono(args: &ResolvedArgs, profile: Option<&Profile>) -> Vec { profile .map(|p| p.deps.values().flatten().cloned().collect()) .or_else(|| args.mono.repos.clone()) diff --git a/src/commands/mono/setup.rs b/src/commands/mono/setup.rs index 2a48601..15b62c8 100644 --- a/src/commands/mono/setup.rs +++ b/src/commands/mono/setup.rs @@ -1,12 +1,14 @@ use crate::repository::repo_dir_name; -use std::{collections::HashSet, iter::once}; +use std::collections::HashSet; /// Builds the full ordered list of repositories, deduplicating by directory name. #[must_use] -pub fn build_repo_list(test_repo: &str, deps: &[String]) -> Vec { +pub fn build_repo_list(test_repos: &[String], deps: &[String]) -> Vec { let mut seen = HashSet::new(); - once(test_repo.to_string()) - .chain(deps.iter().cloned()) + test_repos + .iter() + .chain(deps) + .cloned() .filter(|r| seen.insert(repo_dir_name(r))) .collect() } diff --git a/src/commands/mono/types.rs b/src/commands/mono/types.rs new file mode 100644 index 0000000..d778916 --- /dev/null +++ b/src/commands/mono/types.rs @@ -0,0 +1,11 @@ +use std::path::PathBuf; + +/// Resolved display paths for the setup completion summary. +pub struct SetupPaths { + /// Canonicalized path to the mono-repo root directory. + pub mono_repo_disp: PathBuf, + /// Canonicalized paths to the test repositories executables, if found. + pub exe_paths: Vec<(String, PathBuf)>, + /// Canonicalized path to the build output directory, if no canonical map was provided. + pub build_disp: Option, +} diff --git a/src/commands/single.rs b/src/commands/single.rs index 993c467..cb21b05 100644 --- a/src/commands/single.rs +++ b/src/commands/single.rs @@ -28,13 +28,14 @@ pub fn single_repo_mode( print_mode_header( &ModeHeader { mode: "Single Repository Mode", - test_repo: None, + test_repos: &[], repo_name: Some(&dir_name), - use_ssh: args.connection.ssh, mono_dir: None, profile: None, lib_count: None, repo_count: None, + use_ssh: args.connection.ssh, + verbose: ctx.flags.verbose, }, &mut ctx.io, ); diff --git a/src/profile/display.rs b/src/profile/display.rs index 0fb2646..40fb7af 100644 --- a/src/profile/display.rs +++ b/src/profile/display.rs @@ -13,7 +13,12 @@ pub fn print_profile_details( writeln!(output, " {key}: {repo}").ok(); } if !deps.is_empty() { - writeln!(output, " {label}: {}", deps.values().map(Vec::len).sum::()).ok(); + writeln!( + output, + " {label}: {}", + deps.values().map(Vec::len).sum::() + ) + .ok(); for (key, repos) in deps { for d in repos { writeln!(output, " - [{key}] {d}").ok(); diff --git a/src/profile/types.rs b/src/profile/types.rs index 24f0db4..05bfd05 100644 --- a/src/profile/types.rs +++ b/src/profile/types.rs @@ -1,5 +1,5 @@ -use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; #[derive(Serialize, Deserialize, Default, Clone, Debug)] pub struct Profile { @@ -12,7 +12,7 @@ impl Profile { /// Returns an error if neither a test repo nor any dependency is given. pub fn from_args( test_repos: BTreeMap, - deps: BTreeMap> + deps: BTreeMap>, ) -> Result { if test_repos.is_empty() && deps.is_empty() { return Err("profile requires --test-repo or at least one dependency repo".to_string()); diff --git a/tests/build/npm/watch.rs b/tests/build/npm/watch.rs index 48c33a2..43cbf94 100644 --- a/tests/build/npm/watch.rs +++ b/tests/build/npm/watch.rs @@ -20,8 +20,8 @@ fn test_generate_watch_scripts_creates_files() { with_io_dir(|tmp_path, io| { let repos_path = tmp_path.join("repos"); make_repo_with_scripts(&repos_path, "user-lib1", r#""build": "tsdown""#); - let repos = vec!["user/game".to_string(), "user/lib1".to_string()]; - generate_watch_scripts(tmp_path, &repos_path, &repos, io, make_flags()).unwrap(); + let deps = vec!["user/lib1".to_string()]; + generate_watch_scripts(tmp_path, &repos_path, &deps, io, make_flags()).unwrap(); assert!(tmp_path.join("watch.ps1").exists()); assert!(tmp_path.join("watch.sh").exists()); }); @@ -36,8 +36,8 @@ fn test_generate_watch_scripts_prefers_watch_script() { "user-lib1", r#""build": "tsdown", "watch": "tsdown --watch""#, ); - let repos = vec!["user/game".to_string(), "user/lib1".to_string()]; - generate_watch_scripts(tmp_path, &repos_path, &repos, io, make_flags()).unwrap(); + let deps = vec!["user/lib1".to_string()]; + generate_watch_scripts(tmp_path, &repos_path, &deps, io, make_flags()).unwrap(); let ps1 = read_to_string(tmp_path.join("watch.ps1")).unwrap(); assert!(ps1.contains("run watch")); assert!(!ps1.contains("run build")); @@ -47,10 +47,10 @@ fn test_generate_watch_scripts_prefers_watch_script() { #[test] fn test_generate_watch_scripts_falls_back_to_build() { with_io_dir(|tmp_path, io| { - let repos_path = tmp_path.join("repos"); - make_repo_with_scripts(&repos_path, "user-lib1", r#""build": "tsdown""#); - let repos = vec!["user/game".to_string(), "user/lib1".to_string()]; - generate_watch_scripts(tmp_path, &repos_path, &repos, io, make_flags()).unwrap(); + let deps_path = tmp_path.join("repos"); + make_repo_with_scripts(&deps_path, "user-lib1", r#""build": "tsdown""#); + let deps = vec!["user/lib1".to_string()]; + generate_watch_scripts(tmp_path, &deps_path, &deps, io, make_flags()).unwrap(); let ps1 = read_to_string(tmp_path.join("watch.ps1")).unwrap(); assert!(ps1.contains("run build ''--'' --watch")); }); @@ -59,9 +59,9 @@ fn test_generate_watch_scripts_falls_back_to_build() { #[test] fn test_generate_watch_scripts_empty_libs() { with_io_dir(|tmp_path, io| { - let repos_path = tmp_path.join("repos"); - let repos = vec!["user/game".to_string()]; - generate_watch_scripts(tmp_path, &repos_path, &repos, io, make_flags()).unwrap(); + let deps_path = tmp_path.join("repos"); + let deps = vec![]; + generate_watch_scripts(tmp_path, &deps_path, &deps, io, make_flags()).unwrap(); assert!(!tmp_path.join("watch.ps1").exists()); assert!(!tmp_path.join("watch.sh").exists()); }); @@ -70,15 +70,15 @@ fn test_generate_watch_scripts_empty_libs() { #[test] fn test_generate_watch_scripts_no_scripts_field() { with_io_dir(|tmp_path, io| { - let repos_path = tmp_path.join("repos"); - create_dir_all(repos_path.join("user-lib1")).unwrap(); + let deps_path = tmp_path.join("repos"); + create_dir_all(deps_path.join("user-lib1")).unwrap(); write( - repos_path.join("user-lib1").join("package.json"), + deps_path.join("user-lib1").join("package.json"), r#"{"name": "@user/lib1"}"#, ) .unwrap(); - let repos = vec!["user/game".to_string(), "user/lib1".to_string()]; - generate_watch_scripts(tmp_path, &repos_path, &repos, io, make_flags()).unwrap(); + let deps: Vec = vec!["user/lib1".to_string()]; + generate_watch_scripts(tmp_path, &deps_path, &deps, io, make_flags()).unwrap(); let ps1 = read_to_string(tmp_path.join("watch.ps1")).unwrap(); assert!(!ps1.contains("user-lib1")); }); @@ -88,15 +88,15 @@ fn test_generate_watch_scripts_no_scripts_field() { fn test_generate_watch_scripts_verbose_output() { let ((), out) = with_io_output(|io| { let tmp = TempDir::new().unwrap(); - let repos_path = tmp.path().join("repos"); - make_repo_with_scripts(&repos_path, "user-lib1", r#""build": "tsdown""#); - let repos = vec!["user/game".to_string(), "user/lib1".to_string()]; + let deps_path = tmp.path().join("repos"); + make_repo_with_scripts(&deps_path, "user-lib1", r#""build": "tsdown""#); + let deps = vec!["user/lib1".to_string()]; let flags = RunFlags { verbose: true, timing: false, dry_run: false, }; - generate_watch_scripts(tmp.path(), &repos_path, &repos, io, flags).unwrap(); + generate_watch_scripts(tmp.path(), &deps_path, &deps, io, flags).unwrap(); }); assert!(out.contains("Watching 1 libraries:")); assert!(out.contains("user-lib1")); diff --git a/tests/commands/display.rs b/tests/commands/display.rs index 728cfe4..5b5821c 100644 --- a/tests/commands/display.rs +++ b/tests/commands/display.rs @@ -13,7 +13,7 @@ fn test_print_setup_complete_no_map() { None::<&HashMap>, tmp_path, &tmp_path.join("build"), - "user/repo", + &["user/repo".to_string()], None, ); print_setup_complete(&paths, Instant::now(), io, make_flags()); @@ -35,7 +35,7 @@ fn test_print_setup_complete_with_map() { Some(&map), tmp_path, &tmp_path.join("build"), - "user/repo", + &["user/repo".to_string()], None, ); print_setup_complete(&paths, Instant::now(), io, make_flags()); @@ -43,7 +43,7 @@ fn test_print_setup_complete_with_map() { }); assert!(out.contains("Setup complete")); - assert!(out.contains("Executable:")); + assert!(out.contains("Executables:")); } #[test] @@ -54,7 +54,7 @@ fn test_print_setup_complete_timing() { None::<&HashMap>, tmp_path, &tmp_path.join("build"), - "user/repo", + &["user/repo".to_string()], None, ); print_setup_complete( diff --git a/tests/commands/header.rs b/tests/commands/header.rs index ffb6831..0cb892a 100644 --- a/tests/commands/header.rs +++ b/tests/commands/header.rs @@ -7,13 +7,14 @@ fn test_print_mode_header_repo_name_without_test_repo() { print_mode_header( &ModeHeader { mode: "Single Repository Mode", - test_repo: None, + test_repos: &[], repo_name: Some("myrepo"), use_ssh: false, mono_dir: None, profile: None, lib_count: None, repo_count: None, + verbose: false, }, io, ); @@ -21,3 +22,46 @@ fn test_print_mode_header_repo_name_without_test_repo() { assert!(out.contains("Repository: myrepo")); } + +#[test] +fn test_print_mode_header_test_repos_count_when_not_verbose() { + let ((), out) = with_io_input_output(b"", |io| { + print_mode_header( + &ModeHeader { + mode: "Profile", + test_repos: &["user/game1".to_string(), "user/game2".to_string()], + repo_name: None, + use_ssh: false, + mono_dir: None, + profile: None, + lib_count: None, + repo_count: None, + verbose: false, + }, + io, + ); + }); + assert!(out.contains("Test Repositories: 2")); + assert!(!out.contains("user/game1")); +} + +#[test] +fn test_print_mode_header_test_repos_lists_when_verbose() { + let ((), out) = with_io_input_output(b"", |io| { + print_mode_header( + &ModeHeader { + mode: "Profile", + test_repos: &["user/game1".to_string()], + repo_name: None, + use_ssh: false, + mono_dir: None, + profile: None, + lib_count: None, + repo_count: None, + verbose: true, + }, + io, + ); + }); + assert!(out.contains("user/game1")); +} diff --git a/tests/commands/mono/mode.rs b/tests/commands/mono/mode.rs index 2093986..de2f570 100644 --- a/tests/commands/mono/mode.rs +++ b/tests/commands/mono/mode.rs @@ -1,6 +1,9 @@ use crate::common::{default_resolved_mono, with_ctx, with_ctx_runner, MockRunner}; -use star_setup::{build::BuildSystem, commands::mono_repo_mode, config::Config, ctx::DryRunRunner}; +use star_setup::{ + build::BuildSystem, commands::mono_repo_mode, config::Config, ctx::DryRunRunner, profile::Profile, +}; use std::{ + collections::BTreeMap, fs::{create_dir_all, read_dir, write}, path::Path, }; @@ -80,3 +83,32 @@ fn test_mono_repo_mode_with_build_system_flag() { assert!(runner.calls.iter().any(|(cmd, _)| cmd[0] == "cmake")); } + +#[test] +fn test_mono_repo_mode_multiple_test_repos_opens_all() { + let mut config = Config::new(); + config.profiles.insert( + "multi".to_string(), + Profile { + test_repos: BTreeMap::from([ + ("game1".to_string(), "user/game1".to_string()), + ("game2".to_string(), "user/game2".to_string()), + ]), + deps: BTreeMap::new(), + }, + ); + let mut args = default_resolved_mono(vec![]); + args.repo = None; + args.mono.profile = Some("multi".to_string()); + + let (_, output) = with_ctx(MockRunner::new(), |tmp_path, ctx| { + let repos_path = tmp_path.join(&args.mono.mono_dir).join("repos"); + create_dir_all(&repos_path).unwrap(); + make_cmake_repo(&repos_path, "user-game1"); + make_cmake_repo(&repos_path, "user-game2"); + mono_repo_mode(&args, &config, tmp_path, ctx).unwrap(); + }); + + let out = String::from_utf8(output).unwrap(); + assert!(out.contains("Total repositories: 2")); +} diff --git a/tests/commands/mono/resolve.rs b/tests/commands/mono/resolve.rs index 0ec9533..c552c24 100644 --- a/tests/commands/mono/resolve.rs +++ b/tests/commands/mono/resolve.rs @@ -1,12 +1,13 @@ use crate::common::{default_resolved, with_ctx, MockRunner}; use star_setup::{ build::{generate_mono_config, BuildSystem}, - commands::{mono::resolve_test_repo_for_mono, resolve_repos_for_mono, resolve_test_repo}, - config::Config, + commands::{resolve_dep_repos_for_mono, resolve_test_repo, resolve_test_repos_for_mono}, profile::Profile, }; use std::{ - collections::BTreeMap, fs::{create_dir_all, read_to_string, write}, slice::from_ref, + collections::BTreeMap, + fs::{create_dir_all, read_to_string, write}, + slice::from_ref, }; /* ===== RESOLVE_TEST_REPO ===== */ @@ -52,29 +53,25 @@ fn test_resolve_test_repo_errors() { /* ===== RESOLVE_REPOS_FOR_MONO ===== */ #[test] -fn test_resolve_test_repo_for_mono_errors_when_profile_empty() { - let mut config = Config::new(); - config - .profiles - .insert("emptyprofile".to_string(), Profile::default()); +fn test_resolve_test_repos_for_mono_errors_when_no_repo() { let mut args = default_resolved(); args.repo = None; - assert!(resolve_test_repo_for_mono(&args).is_err()); + assert!(resolve_test_repos_for_mono(&args, None).is_err()); } #[test] -fn test_resolve_repos_for_mono_with_profile() { +fn test_resolve_test_repos_for_mono_from_profile() { let profile = Profile { - test_repos: BTreeMap::new(), - deps: BTreeMap::from([( - "default".to_string(), - vec!["user/lib1".to_string(), "user/lib2".to_string()], - )]), + test_repos: BTreeMap::from([ + ("game1".to_string(), "user/game1".to_string()), + ("game2".to_string(), "user/game2".to_string()), + ]), + deps: BTreeMap::new(), }; let args = default_resolved(); assert_eq!( - resolve_repos_for_mono(&args, Some(&profile)), - vec!["user/lib1", "user/lib2"] + resolve_test_repos_for_mono(&args, Some(&profile)), + Ok(vec!["user/game1".to_string(), "user/game2".to_string()]) ); } @@ -83,7 +80,7 @@ fn test_resolve_repos_for_mono_with_explicit_repos() { let mut args = default_resolved(); args.mono.repos = Some(vec!["user/lib1".to_string(), "user/lib2".to_string()]); assert_eq!( - resolve_repos_for_mono(&args, None), + resolve_dep_repos_for_mono(&args, None), vec!["user/lib1", "user/lib2"] ); } diff --git a/tests/commands/mono/setup.rs b/tests/commands/mono/setup.rs index 6e89724..f999532 100644 --- a/tests/commands/mono/setup.rs +++ b/tests/commands/mono/setup.rs @@ -7,33 +7,44 @@ fn to_string_vec(slice: &[&str]) -> Vec { #[test] fn test_build_repo_list_test_repo_first() { let deps = to_string_vec(&["user/lib1", "user/lib2"]); - let result = build_repo_list("user/testrepo", &deps); + let result = build_repo_list(&["user/testrepo".to_string()], &deps); assert_eq!(result[0], "user/testrepo"); } #[test] fn test_build_repo_list_includes_deps() { let deps = to_string_vec(&["user/lib1", "user/lib2"]); - let result = build_repo_list("user/testrepo", &deps); + let result = build_repo_list(&["user/testrepo".to_string()], &deps); assert_eq!(result.len(), 3); } #[test] fn test_build_repo_list_dedupes_test_repo_in_deps() { let deps = to_string_vec(&["user/lib1", "user/testrepo"]); - let result = build_repo_list("user/testrepo", &deps); + let result = build_repo_list(&["user/testrepo".to_string()], &deps); assert_eq!(result, to_string_vec(&["user/testrepo", "user/lib1"])); } #[test] fn test_build_repo_list_dedupes_duplicate_deps() { let deps = to_string_vec(&["user/lib1", "user/lib1"]); - let result = build_repo_list("user/testrepo", &deps); + let result = build_repo_list(&["user/testrepo".to_string()], &deps); assert_eq!(result, to_string_vec(&["user/testrepo", "user/lib1"])); } #[test] fn test_build_repo_list_no_deps() { - let result = build_repo_list("user/testrepo", &[]); + let result = build_repo_list(&["user/testrepo".to_string()], &[]); assert_eq!(result, to_string_vec(&["user/testrepo"])); } + +#[test] +fn test_build_repo_list_multiple_test_repos() { + let test_repos = to_string_vec(&["user/game1", "user/game2"]); + let deps = to_string_vec(&["user/lib1"]); + let result = build_repo_list(&test_repos, &deps); + assert_eq!( + result, + to_string_vec(&["user/game1", "user/game2", "user/lib1"]) + ); +} diff --git a/tests/profile/crud.rs b/tests/profile/crud.rs index cd443b6..abaa20c 100644 --- a/tests/profile/crud.rs +++ b/tests/profile/crud.rs @@ -1,4 +1,3 @@ -use std::collections::BTreeMap; use crate::common::{make_flags, with_io_dir, with_io_input_output, with_io_output}; use star_setup::{ config::{load_config, save_config, Config}, @@ -6,6 +5,7 @@ use star_setup::{ add_profile, has_profile, insert_profile, remove_profile, remove_profile_entry, Profile, }, }; +use std::collections::BTreeMap; use tempfile::TempDir; /* ===== HAS_PROFILE ===== */ @@ -53,7 +53,10 @@ fn test_save_and_load_profile_roundtrip() { "myprofile", Profile { test_repos: BTreeMap::new(), - deps: BTreeMap::from([("default".to_string(), vec!["user/repo1".to_string(), "user/repo2".to_string()])]), + deps: BTreeMap::from([( + "default".to_string(), + vec!["user/repo1".to_string(), "user/repo2".to_string()], + )]), }, ); @@ -78,7 +81,7 @@ fn test_insert_profile() { "myprofile", Profile { test_repos: BTreeMap::new(), - deps: BTreeMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), + deps: BTreeMap::from([("default".to_string(), vec!["user/repo1".to_string()])]), }, ); @@ -129,7 +132,10 @@ fn test_add_profile_overwrites_existing() { make_flags(), ) .unwrap(); - assert_eq!( config.profiles["myprofile"].deps["default"], vec!["new/repo"]); + assert_eq!( + config.profiles["myprofile"].deps["default"], + vec!["new/repo"] + ); }); } @@ -144,11 +150,14 @@ fn test_add_profile_multiple_repos() { "myprofile", &Profile { test_repos: BTreeMap::new(), - deps: BTreeMap::from([("default".to_string(), vec![ - "user/repo1".to_string(), - "user/repo2".to_string(), - "user/repo3".to_string(), - ])]), + deps: BTreeMap::from([( + "default".to_string(), + vec![ + "user/repo1".to_string(), + "user/repo2".to_string(), + "user/repo3".to_string(), + ], + )]), }, true, io, @@ -172,7 +181,7 @@ fn test_add_profile_aborts_when_exists_and_not_confirmed() { "myprofile", Profile { test_repos: BTreeMap::new(), - deps: BTreeMap::from([("default".to_string(), vec!["old/repo".to_string()])]), + deps: BTreeMap::from([("default".to_string(), vec!["old/repo".to_string()])]), }, ); add_profile( @@ -180,14 +189,17 @@ fn test_add_profile_aborts_when_exists_and_not_confirmed() { "myprofile", &Profile { test_repos: BTreeMap::new(), - deps: BTreeMap::from([("default".to_string(), vec!["new/repo".to_string()])]), + deps: BTreeMap::from([("default".to_string(), vec!["new/repo".to_string()])]), }, false, io, make_flags(), ) .unwrap(); - assert_eq!(config.profiles["myprofile"].deps["default"], vec!["old/repo"]); + assert_eq!( + config.profiles["myprofile"].deps["default"], + vec!["old/repo"] + ); }); } diff --git a/tests/profile/display.rs b/tests/profile/display.rs index 458510e..b8bb347 100644 --- a/tests/profile/display.rs +++ b/tests/profile/display.rs @@ -1,9 +1,9 @@ -use std::collections::BTreeMap; use crate::common::with_io_output; use star_setup::{ config::Config, profile::{insert_profile, list_profiles, Profile}, }; +use std::collections::BTreeMap; #[test] fn test_list_profiles_empty() { diff --git a/tests/resolve/resolve.rs b/tests/resolve/resolve.rs index 393ad7a..25a9af6 100644 --- a/tests/resolve/resolve.rs +++ b/tests/resolve/resolve.rs @@ -1,10 +1,10 @@ -use std::collections::BTreeMap; use crate::common::default_args; use star_setup::{ build::BuildType, config::{Config, ConfigEntry}, resolve::{resolve_bool, resolve_with_config}, }; +use std::collections::BTreeMap; /// Helper to quickly build a `SetupConfig` with a populated profile entry. fn config_with_entry(name: &str, entry: ConfigEntry) -> Config { From 70f1fedfcfb2ce699c66b6dd14a6147114184c8c Mon Sep 17 00:00:00 2001 From: Masonlet Date: Thu, 16 Jul 2026 12:33:28 -0400 Subject: [PATCH 06/12] refactor: default missing Profile fields --- src/profile/types.rs | 2 ++ tests/profile/types.rs | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/src/profile/types.rs b/src/profile/types.rs index 05bfd05..93ee0d2 100644 --- a/src/profile/types.rs +++ b/src/profile/types.rs @@ -3,7 +3,9 @@ use std::collections::BTreeMap; #[derive(Serialize, Deserialize, Default, Clone, Debug)] pub struct Profile { + #[serde(default)] pub test_repos: BTreeMap, + #[serde(default)] pub deps: BTreeMap>, } diff --git a/tests/profile/types.rs b/tests/profile/types.rs index 50e31f4..2e9d9d1 100644 --- a/tests/profile/types.rs +++ b/tests/profile/types.rs @@ -1,5 +1,6 @@ use star_setup::profile::Profile; use std::collections::BTreeMap; +use serde_json::from_str; #[test] fn test_profile_from_args_errors_when_empty() { @@ -11,3 +12,10 @@ fn test_profile_from_args_test_repo_only() { let test_repos = BTreeMap::from([("default".to_string(), "user/app".to_string())]); assert!(Profile::from_args(test_repos, BTreeMap::new()).is_ok()); } + +#[test] +fn test_profile_deserializes_with_missing_fields() { + let profile: Profile = from_str(r#"{"deps": {"a": ["user/lib"]}}"#).unwrap(); + assert!(profile.test_repos.is_empty()); + assert_eq!(profile.deps["a"], vec!["user/lib"]); +} From 53e4f8218fe8232e1661dc6b456aff9f896b5cea Mon Sep 17 00:00:00 2001 From: Masonlet Date: Thu, 16 Jul 2026 14:03:32 -0400 Subject: [PATCH 07/12] refactor: extract shared terminal-script generation for watch and dev --- src/build/mod.rs | 5 +- src/build/npm/dev.rs | 39 ++++++++++++- src/build/npm/mod.rs | 6 +- src/build/npm/scripts.rs | 113 ++++++++++++++++++++++++++++++++++++++ src/build/npm/watch.rs | 98 +++------------------------------ src/commands/mono/mode.rs | 29 ++++++---- tests/build/npm/dev.rs | 56 ++++++++++++++++++- tests/profile/types.rs | 2 +- 8 files changed, 237 insertions(+), 111 deletions(-) create mode 100644 src/build/npm/scripts.rs diff --git a/src/build/mod.rs b/src/build/mod.rs index d058bee..f804701 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -14,6 +14,7 @@ pub use meson::{ }; pub mod npm; pub use npm::{ - create_mono_repo_package_json, generate_watch_scripts, maybe_open_dev_server, npm_build, - open_dev_server, open_watch_scripts, read_package_json, resolve_dev_command, + create_mono_repo_package_json, generate_dev_scripts, generate_terminal_scripts, + generate_watch_scripts, maybe_open_dev_server, npm_build, open_dev_server, open_scripts, + read_package_json, resolve_dev_command, }; diff --git a/src/build/npm/dev.rs b/src/build/npm/dev.rs index b6e71c8..ee5000a 100644 --- a/src/build/npm/dev.rs +++ b/src/build/npm/dev.rs @@ -2,15 +2,16 @@ use crate::utils::process::resolve_exe_args; use crate::{ build::{ - read_package_json, + generate_terminal_scripts, read_package_json, BuildSystem::{self, Npm}, }, ctx::{IoCtx, RunCtx, RunFlags}, + repository::repo_dir_name, resolve::ResolvedArgs, }; use serde_json::Value; use std::{ - path::Path, + path::{Path, PathBuf}, process::{Command, Stdio}, }; @@ -123,3 +124,37 @@ pub fn maybe_open_dev_server( } Ok(()) } + +/// Generates `dev.ps1` / `dev.sh` launching each test repo's dev server in its +/// own terminal, so the whole set can be reopened by rerunning the script. +/// # Errors +/// Returns an error if the scripts cannot be written. +pub fn generate_dev_scripts( + mono_dir: &Path, + repos_path: &Path, + test_repos: &[String], + io: &mut IoCtx<'_>, + flags: RunFlags, +) -> Result { + if test_repos.is_empty() { + return Ok(false); + } + let entries: Vec<(PathBuf, String)> = test_repos + .iter() + .filter_map(|r| { + let dir = repo_dir_name(r); + resolve_dev_command(&repos_path.join(&dir), io, flags) + .map(|cmd| (mono_dir.join("repos").join(&dir), cmd)) + }) + .collect(); + + generate_terminal_scripts( + "dev", + "Run all test repositories", + mono_dir, + &entries, + io, + flags, + )?; + Ok(true) +} diff --git a/src/build/npm/mod.rs b/src/build/npm/mod.rs index 62e37d7..d15b527 100644 --- a/src/build/npm/mod.rs +++ b/src/build/npm/mod.rs @@ -2,9 +2,11 @@ pub mod build; pub use build::npm_build; pub mod config; pub use config::create_mono_repo_package_json; +pub mod scripts; +pub use scripts::{generate_terminal_scripts, open_scripts}; pub mod watch; -pub use watch::{generate_watch_scripts, open_watch_scripts}; +pub use watch::generate_watch_scripts; pub mod dev; -pub use dev::{maybe_open_dev_server, open_dev_server, resolve_dev_command}; +pub use dev::{generate_dev_scripts, maybe_open_dev_server, open_dev_server, resolve_dev_command}; pub mod package; pub use package::read_package_json; diff --git a/src/build/npm/scripts.rs b/src/build/npm/scripts.rs new file mode 100644 index 0000000..88e2359 --- /dev/null +++ b/src/build/npm/scripts.rs @@ -0,0 +1,113 @@ +use crate::{ + ctx::{IoCtx, RunFlags}, + utils::{dry_run_or_do, report_summary}, +}; +use std::{ + fs::write, + path::{Path, PathBuf}, + process::Command, +}; + +/// Writes `.ps1` / `.sh` launching each `(dir, cmd)` entry in its own +/// terminal, so the set can be reopened later by rerunning the script. +/// # Errors +/// Returns an error if the scripts cannot be written. +pub fn generate_terminal_scripts( + name: &str, + header: &str, + mono_dir: &Path, + entries: &[(PathBuf, String)], + io: &mut IoCtx<'_>, + flags: RunFlags, +) -> Result<(), String> { + let ps1_lines: Vec = entries + .iter() + .map(|(dir, cmd)| { + format!( + "Start-Process powershell -ArgumentList '-NoExit', '-Command', 'cd \"{}\"; {cmd}'", + dir.display() + ) + }) + .collect(); + let sh_lines: Vec = entries + .iter() + .map(|(dir, cmd)| format!("cd \"{}\" && {cmd} &", dir.display())) + .collect(); + + let ps1_content = format!("# {header}\n{}\n", ps1_lines.join("\n")); + let sh_content = format!( + "#!/bin/bash\ntrap 'kill $(jobs -p)' EXIT\n# {header}\n{}\nwait\n", + sh_lines.join("\n") + ); + + let ps1_name = format!("{name}.ps1"); + let sh_name = format!("{name}.sh"); + dry_run_or_do( + &format!("write {name} scripts"), + "Writing", + mono_dir, + io, + flags, + "Write scripts", + || { + write(mono_dir.join(&ps1_name), ps1_content) + .map_err(|e| format!("Failed to write {ps1_name}: {e}"))?; + write(mono_dir.join(&sh_name), sh_content) + .map_err(|e| format!("Failed to write {sh_name}: {e}"))?; + Ok(()) + }, + )?; + + report_summary( + io, + flags, + &format!("generate {name} scripts at {}", mono_dir.display()), + &format!("Generated {name} scripts at {}", mono_dir.display()), + ); + Ok(()) +} + +/// Opens the generated `` scripts in new terminals. +/// # Errors +/// Returns an error if the terminal cannot be opened. +pub fn open_scripts( + name: &str, + mono_dir: &Path, + io: &mut IoCtx<'_>, + flags: RunFlags, +) -> Result<(), String> { + if flags.dry_run { + if flags.verbose { + writeln!(io.output, " Would open {name} scripts").ok(); + } + return Ok(()); + } + + crate::time!(flags.timing, io.output, "Open", { + #[cfg(target_os = "windows")] + { + let ps1_path = mono_dir.join(format!("{name}.ps1")); + Command::new("powershell") + .args([ + "-ExecutionPolicy", + "Bypass", + "-File", + ps1_path.to_str().ok_or("Invalid path")?, + ]) + .spawn() + .map_err(|e| format!("Failed to open {name}.ps1: {e}"))?; + } + + #[cfg(not(target_os = "windows"))] + { + let sh_path = mono_dir.join(format!("{name}.sh")); + Command::new("bash") + .arg(sh_path.to_str().ok_or("Invalid path")?) + .spawn() + .map_err(|e| format!("Failed to open {name}.sh: {e}"))?; + } + Ok::<(), String>(()) + })?; + writeln!(io.output, " Opening {name} scripts").ok(); + Ok(()) +} diff --git a/src/build/npm/watch.rs b/src/build/npm/watch.rs index bedee8b..ae9a41d 100644 --- a/src/build/npm/watch.rs +++ b/src/build/npm/watch.rs @@ -1,12 +1,10 @@ use crate::{ - build::read_package_json, + build::{generate_terminal_scripts, read_package_json}, ctx::{IoCtx, RunFlags}, repository::repo_dir_name, - utils::{dry_run_or_do, report_summary}, }; use dunce::canonicalize; -use std::process::Command; -use std::{fs::write, path::Path}; +use std::path::{Path, PathBuf}; /// Reads a lib's package.json and returns the appropriate watch command. fn get_watch_command( @@ -46,60 +44,26 @@ pub fn generate_watch_scripts( flags: RunFlags, ) -> Result { let lib_dirs: Vec = deps.iter().map(|r| repo_dir_name(r)).collect(); - if lib_dirs.is_empty() { return Ok(false); } - let watch_cmds: Vec = lib_dirs - .iter() - .filter_map(|d| get_watch_command(repos_path, d, io, flags)) - .collect(); - - let ps1_lines: Vec = watch_cmds + let entries: Vec<(PathBuf, String)> = lib_dirs .iter() - .map(|cmd| { - format!( - "Start-Process powershell -ArgumentList '-NoExit', '-Command', 'cd \"{}\"; {cmd}'", - mono_dir.display() - ) + .filter_map(|d| { + get_watch_command(repos_path, d, io, flags).map(|cmd| (mono_dir.to_path_buf(), cmd)) }) .collect(); - let sh_lines: Vec = watch_cmds - .iter() - .map(|cmd| format!("cd \"{}\" && {cmd} &", mono_dir.display())) - .collect(); - - let ps1_content = format!("# Watch all lib repositories\n{}\n", ps1_lines.join("\n")); - let sh_content = format!( - "#!/bin/bash\ntrap 'kill $(jobs -p)' EXIT\n# Watch all lib repositories\n{}\nwait\n", - sh_lines.join("\n") - ); - - dry_run_or_do( - "write watch scripts", - "Writing", + generate_terminal_scripts( + "watch", + "Watch all lib repositories", mono_dir, + &entries, io, flags, - "Write scripts", - || { - write(mono_dir.join("watch.ps1"), ps1_content) - .map_err(|e| format!("Failed to write watch.ps1: {e}"))?; - write(mono_dir.join("watch.sh"), sh_content) - .map_err(|e| format!("Failed to write watch.sh: {e}"))?; - Ok(()) - }, )?; - report_summary( - io, - flags, - &format!("generate watch scripts at {}", mono_dir.display()), - &format!("Generated watch scripts at {}", mono_dir.display()), - ); - if flags.verbose { writeln!(io.output, " Watching {} libraries:", lib_dirs.len()).ok(); for d in &lib_dirs { @@ -110,47 +74,3 @@ pub fn generate_watch_scripts( Ok(true) } - -/// Opens watch scripts in new terminals. -/// # Errors -/// Returns an error if the terminal cannot be opened. -pub fn open_watch_scripts( - mono_dir: &Path, - io: &mut IoCtx<'_>, - flags: RunFlags, -) -> Result<(), String> { - if flags.dry_run { - if flags.verbose { - writeln!(io.output, " Would open watch scripts").ok(); - } - return Ok(()); - } - - crate::time!(flags.timing, io.output, "Open", { - #[cfg(target_os = "windows")] - { - let ps1_path = mono_dir.join("watch.ps1"); - Command::new("powershell") - .args([ - "-ExecutionPolicy", - "Bypass", - "-File", - ps1_path.to_str().ok_or("Invalid path")?, - ]) - .spawn() - .map_err(|e| format!("Failed to open watch.ps1: {e}"))?; - } - - #[cfg(not(target_os = "windows"))] - { - let sh_path = mono_dir.join("watch.sh"); - Command::new("bash") - .arg(sh_path.to_str().ok_or("Invalid path")?) - .spawn() - .map_err(|e| format!("Failed to open watch.sh: {e}"))?; - } - Ok::<(), String>(()) - })?; - writeln!(io.output, " Opening watch scripts").ok(); - Ok(()) -} diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index 2cf9da9..42d2747 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -1,7 +1,7 @@ use crate::{ build::{ - build_project, detect_mono_build_system, generate_mono_config, generate_watch_scripts, - maybe_open_dev_server, open_watch_scripts, BuildSystem::Npm, + build_project, detect_mono_build_system, generate_dev_scripts, generate_mono_config, + generate_watch_scripts, open_scripts, BuildSystem::Npm, }, commands::{ build_repo_list, @@ -105,7 +105,21 @@ pub fn mono_repo_mode( && generate_watch_scripts(&mono_repo_path, &repos_path, &deps, &mut ctx.io, ctx.flags)? && args.build.watch { - open_watch_scripts(&mono_repo_path, &mut ctx.io, ctx.flags)?; + open_scripts("watch", &mono_repo_path, &mut ctx.io, ctx.flags)?; + } + + if build_system == Some(Npm) + && !args.build.no_dev + && generate_dev_scripts( + &mono_repo_path, + &repos_path, + &test_repos, + &mut ctx.io, + ctx.flags, + )? + && args.build.dev + { + open_scripts("dev", &mono_repo_path, &mut ctx.io, ctx.flags)?; } let paths = if ctx.flags.dry_run { @@ -138,14 +152,5 @@ pub fn mono_repo_mode( } else { print_setup_complete(&paths, total, &mut ctx.io, ctx.flags); } - - let test_repo_dirs: Vec = test_repos - .iter() - .map(|r| repos_path.join(repo_dir_name(r))) - .collect(); - - for dir in &test_repo_dirs { - maybe_open_dev_server(args, build_system, dir, ctx)?; - } Ok(()) } diff --git a/tests/build/npm/dev.rs b/tests/build/npm/dev.rs index 66f616b..93aca4c 100644 --- a/tests/build/npm/dev.rs +++ b/tests/build/npm/dev.rs @@ -1,6 +1,9 @@ -use crate::common::with_io_output; -use star_setup::{build::resolve_dev_command, ctx::RunFlags}; -use std::fs::{create_dir_all, write}; +use crate::common::{make_flags, with_io_dir, with_io_output}; +use star_setup::{ + build::{generate_dev_scripts, resolve_dev_command}, + ctx::RunFlags, +}; +use std::fs::{create_dir_all, read_to_string, write}; use tempfile::TempDir; fn flags(verbose: bool) -> RunFlags { @@ -77,3 +80,50 @@ fn test_resolve_dev_command_none_when_no_signals() { assert!(result.is_none()); assert!(out.contains("No dev script found")); } + +#[test] +fn test_generate_dev_scripts_creates_files() { + with_io_dir(|tmp_path, io| { + let repos_path = tmp_path.join("repos"); + create_dir_all(repos_path.join("user-game")).unwrap(); + write( + repos_path.join("user-game").join("package.json"), + r#"{"scripts":{"dev":"vite"}}"#, + ) + .unwrap(); + let test_repos = vec!["user/game".to_string()]; + assert!(generate_dev_scripts(tmp_path, &repos_path, &test_repos, io, make_flags()).unwrap()); + let ps1 = read_to_string(tmp_path.join("dev.ps1")).unwrap(); + assert!(ps1.contains("npm run dev")); + assert!(ps1.contains("user-game")); + assert!(tmp_path.join("dev.sh").exists()); + }); +} + +#[test] +fn test_generate_dev_scripts_empty() { + with_io_dir(|tmp_path, io| { + assert!( + !generate_dev_scripts(tmp_path, &tmp_path.join("repos"), &[], io, make_flags()).unwrap() + ); + assert!(!tmp_path.join("dev.ps1").exists()); + }); +} + +#[test] +fn test_generate_dev_scripts_vercel_fallback() { + with_io_dir(|tmp_path, io| { + let repos_path = tmp_path.join("repos"); + create_dir_all(repos_path.join("user-game")).unwrap(); + write( + repos_path.join("user-game").join("package.json"), + r#"{"scripts":{}}"#, + ) + .unwrap(); + write(repos_path.join("user-game").join("vercel.json"), "{}").unwrap(); + let test_repos = vec!["user/game".to_string()]; + generate_dev_scripts(tmp_path, &repos_path, &test_repos, io, make_flags()).unwrap(); + let ps1 = read_to_string(tmp_path.join("dev.ps1")).unwrap(); + assert!(ps1.contains("vercel dev")); + }); +} diff --git a/tests/profile/types.rs b/tests/profile/types.rs index 2e9d9d1..685b3e1 100644 --- a/tests/profile/types.rs +++ b/tests/profile/types.rs @@ -1,6 +1,6 @@ +use serde_json::from_str; use star_setup::profile::Profile; use std::collections::BTreeMap; -use serde_json::from_str; #[test] fn test_profile_from_args_errors_when_empty() { From a7c24529cf86fd30510b0c06fdbd0f0b6b418b4a Mon Sep 17 00:00:00 2001 From: Masonlet Date: Thu, 16 Jul 2026 20:51:47 -0400 Subject: [PATCH 08/12] feat: support multiple test repos in profile add --- README.md | 9 ++++++--- src/cli/commands.rs | 11 ++++++----- src/commands/handlers.rs | 21 +++++++++------------ 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 4868680..0032603 100644 --- a/README.md +++ b/README.md @@ -283,11 +283,14 @@ star-setup username/repo --config myconfig ### Profile Mode Profiles represent a saved ecosystem of libraries commonly used together. ```bash -# Add a profile with a test repo and dependencies -star-setup profile add myprofile --test-repo user/app user/lib1 user/lib2 +# Add a single-game profile +star-setup profile add myprofile --test-repos user/app --deps user/lib1 user/lib2 + +# Add a profile with test repos and shared dependencies +star-setup profile add myprofile --test-repos user/game1 user/game2 --deps user/lib1 user/lib2 # Add a dependency-only profile -star-setup profile add myprofile user/lib1 user/lib2 +star-setup profile add myprofile --deps user/lib1 user/lib2 # List profiles star-setup profile list diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 4bcef76..2f82e2f 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -51,11 +51,12 @@ pub enum ProfileAction { Add { /// Name of the profile. name: String, - /// Test repository (user/repo). - #[arg(long)] - test_repo: Option, - /// Repository list (user/lib1 user/lib2). - repos: Vec, + /// Test repositories (user/game1 user/game2). + #[arg(long, num_args = 1..)] + test_repos: Vec, + /// Dependency repositories (user/lib1 user/lib2). + #[arg(long, num_args = 1..)] + deps: Vec, }, /// Remove a named profile. Remove { diff --git a/src/commands/handlers.rs b/src/commands/handlers.rs index 8d172ce..e94c0cc 100644 --- a/src/commands/handlers.rs +++ b/src/commands/handlers.rs @@ -2,11 +2,7 @@ use crate::{ cli::{ ConfigAction, ProfileAction, WorkspaceAction::{self, Clean, Status, Update}, - }, - config::{add_config, create_default_config, list_configs, remove_config, Config, ConfigEntry}, - ctx::{with_runner, IoCtx, RunFlags}, - profile::{add_profile, list_profiles, remove_profile, Profile}, - workspace::resolve_workspace, + }, config::{Config, ConfigEntry, add_config, create_default_config, list_configs, remove_config}, ctx::{IoCtx, RunFlags, with_runner}, profile::{Profile, add_profile, list_profiles, remove_profile}, repository::repo_dir_name, workspace::resolve_workspace, }; use std::{collections::BTreeMap, error::Error, path::PathBuf}; @@ -54,16 +50,17 @@ pub fn handle_profile_cmd( ProfileAction::Remove { name } => remove_profile(config, &name, yes, io, flags)?, ProfileAction::Add { name, - test_repo, - repos, + test_repos, + deps, } => { - let test_repos = test_repo - .map(|r| BTreeMap::from([("default".to_string(), r)])) - .unwrap_or_default(); - let deps = if repos.is_empty() { + let test_repos: BTreeMap = test_repos + .into_iter() + .map(|r| (repo_dir_name(&r), r)) + .collect(); + let deps = if deps.is_empty() { BTreeMap::new() } else { - BTreeMap::from([("default".to_string(), repos)]) + BTreeMap::from([("default".to_string(), deps)]) }; let profile = Profile::from_args(test_repos, deps)?; add_profile(config, &name, &profile, yes, io, flags)?; From dc84b92b1b499d7475b4a4fabcba83b102ece84d Mon Sep 17 00:00:00 2001 From: Masonlet Date: Thu, 16 Jul 2026 21:07:34 -0400 Subject: [PATCH 09/12] docs: update README for multi-repo profiles and dev script generation --- README.md | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0032603..56796ce 100644 --- a/README.md +++ b/README.md @@ -136,17 +136,14 @@ star-setup username/repo ``` ### Mono-Repo Mode -Clones multiple repositories into a single workspace and auto-detects the build system. +Clones multiple repositories into a single workspace and auto-detects the build system. A profile can hold several test repos and dependencies. ```bash # Clone and build a test repo and a manual repo list star-setup username/repo --repos user/lib1 user/lib2 -# Clone and build a saved profile +# Clone and build every test repo + dependency on a saved profile star-setup --profile myprofile - -# Override a profile's test repo -star-setup username/repo --profile myprofile ``` #### Workspace Structures @@ -206,13 +203,15 @@ build-mono/ ├── package.json # Auto-generated workspace root with workspaces + overrides ├── watch.ps1 # Auto-generated watch script (Windows) ├── watch.sh # Auto-generated watch script (Linux/macOS) +├── dev.ps1 # Auto-generated dev-server script (Windows) +├── dev.sh # Auto-generated dev-server script (Linux/macOS) └── repos/ ├── user-my-repo/ # Test repository ├── user-lib1/ └── user-lib2/ ``` -Watch scripts are generated by default and run each lib's `watch` script if present, falling back to `build -- --watch`. Use `--watch` to open them automatically in new terminals, or `--no-watch` to skip generation entirely. +Watch/dev scripts are generated by default and run each repo's `watch`/`dev` script if present, falling back to `build -- --watch` / `--dev`. Use `--watch`/`--dev` to open them automatically in new terminals, or `--no-watch`/`--no-dev` to skip generation entirely. ```bash # Generate workspace and open watchers @@ -225,12 +224,7 @@ star-setup username/repo --repos user/lib1 user/lib2 star-setup username/repo --repos user/lib1 user/lib2 --no-watch ``` -After setup pass `--dev` to launch it automatically: falls back to `vercel dev` if the test repo has no `dev` script but looks like a Vercel project. Or, run it manually from its repo directory: -```bash -cd build-mono/repos/user-my-repo -# npm run dev -# vercel dev -``` +Rerun `dev.ps1` / `dev.sh` anytime to reopen every dev server without rebuilding. @@ -302,6 +296,26 @@ star-setup profile remove myprofile star-setup --profile myprofile ``` +Profiles are stored in `.star-setup.json`: +```json +{ + "profiles": { + "myprofile": { + "test_repos": { + "user-game1": "user/game1", + "user-game2": "user/game2" + }, + "deps": { + "default": [ + "user/lib1", + "user/lib2" + ] + } + } + } +} +``` + ### Development ```bash git clone https://github.com/star-setup/core From 6cffa6457f5af376442d69b9168a3e78778a5375 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Thu, 16 Jul 2026 21:08:21 -0400 Subject: [PATCH 10/12] chore: bump v0.6.0 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 7e20d1a..c6c7e64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "star-setup" -version = "0.5.0" +version = "0.6.0" edition = "2021" repository = "https://github.com/star-setup/core" description = "Lightweight CLI to clone, configure, and wire single or multi-repo ecosystems" From 48233445e4ab96a2002889d207698691ec752a9f Mon Sep 17 00:00:00 2001 From: Masonlet Date: Thu, 16 Jul 2026 21:16:48 -0400 Subject: [PATCH 11/12] refactor: drop dead profile-repo fill from resolve_repo --- src/commands/handlers.rs | 7 ++++++- src/resolve/resolver.rs | 22 +++++++--------------- tests/resolve/resolve.rs | 36 ------------------------------------ 3 files changed, 13 insertions(+), 52 deletions(-) diff --git a/src/commands/handlers.rs b/src/commands/handlers.rs index e94c0cc..748a312 100644 --- a/src/commands/handlers.rs +++ b/src/commands/handlers.rs @@ -2,7 +2,12 @@ use crate::{ cli::{ ConfigAction, ProfileAction, WorkspaceAction::{self, Clean, Status, Update}, - }, config::{Config, ConfigEntry, add_config, create_default_config, list_configs, remove_config}, ctx::{IoCtx, RunFlags, with_runner}, profile::{Profile, add_profile, list_profiles, remove_profile}, repository::repo_dir_name, workspace::resolve_workspace, + }, + config::{add_config, create_default_config, list_configs, remove_config, Config, ConfigEntry}, + ctx::{with_runner, IoCtx, RunFlags}, + profile::{add_profile, list_profiles, remove_profile, Profile}, + repository::repo_dir_name, + workspace::resolve_workspace, }; use std::{collections::BTreeMap, error::Error, path::PathBuf}; diff --git a/src/resolve/resolver.rs b/src/resolve/resolver.rs index a4e476c..1657e58 100644 --- a/src/resolve/resolver.rs +++ b/src/resolve/resolver.rs @@ -120,25 +120,17 @@ fn resolve_repo( profile: Option<&str>, config: &Config, ) -> Result, String> { - let prof = match profile { - Some(name) => Some(config.profiles.get(name).ok_or_else(|| { + if let Some(name) = profile { + if !config.profiles.contains_key(name) { let mut names: Vec<&str> = config.profiles.keys().map(String::as_str).collect(); names.sort_unstable(); - format!( + return Err(format!( "Profile '{name}' not found. Available: {}", names.join(", ") - ) - })?), - None => None, - }; - Ok(repo.or_else(|| { - prof.and_then(|p| { - p.test_repos - .get("default") - .or_else(|| p.test_repos.values().next()) - .cloned() - }) - })) + )); + } + } + Ok(repo) } /// Resolves raw `Args` into `ResolvedArgs` by applying config defaults and CLI overrides. diff --git a/tests/resolve/resolve.rs b/tests/resolve/resolve.rs index 25a9af6..48afd7c 100644 --- a/tests/resolve/resolve.rs +++ b/tests/resolve/resolve.rs @@ -4,7 +4,6 @@ use star_setup::{ config::{Config, ConfigEntry}, resolve::{resolve_bool, resolve_with_config}, }; -use std::collections::BTreeMap; /// Helper to quickly build a `SetupConfig` with a populated profile entry. fn config_with_entry(name: &str, entry: ConfigEntry) -> Config { @@ -291,41 +290,6 @@ fn test_resolve_with_config_cli_positives_override_no_watch_no_dev_config() { assert!(!resolved.build.no_dev); } -#[test] -fn test_resolve_fills_repo_from_profile_test_repo() { - let mut config = Config::new(); - config.profiles.insert( - "p".to_string(), - star_setup::profile::Profile { - test_repos: BTreeMap::from([("default".to_string(), "user/app".to_string())]), - deps: BTreeMap::new(), - }, - ); - let mut args = default_args(); - args.mono.profile = Some("p".to_string()); - let resolved = resolve_with_config(args, &config).unwrap(); - assert_eq!(resolved.repo, Some("user/app".to_string())); -} - -#[test] -fn test_resolve_positional_beats_profile_test_repo() { - let mut config = Config::new(); - config.profiles.insert( - "p".to_string(), - star_setup::profile::Profile { - test_repos: BTreeMap::from([("default".to_string(), "user/other".to_string())]), - deps: BTreeMap::new(), - }, - ); - let mut args = default_args(); - args.repo = Some("user/repo".to_string()); - args.mono.profile = Some("p".to_string()); - assert_eq!( - resolve_with_config(args, &config).unwrap().repo, - Some("user/repo".to_string()) - ); -} - #[test] fn test_resolve_unknown_profile_errors() { let mut args = default_args(); From 3b6a62b632ee2d3ed285b9c1b911d4f0e8291fd8 Mon Sep 17 00:00:00 2001 From: Masonlet Date: Thu, 16 Jul 2026 21:48:22 -0400 Subject: [PATCH 12/12] refactor: extract npm script generation and clippy pedantic --- src/commands/mono/mode.rs | 63 +++++++++++++++++++++++++------------- src/commands/mono/setup.rs | 2 +- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/commands/mono/mode.rs b/src/commands/mono/mode.rs index 42d2747..1fc180f 100644 --- a/src/commands/mono/mode.rs +++ b/src/commands/mono/mode.rs @@ -1,7 +1,7 @@ use crate::{ build::{ build_project, detect_mono_build_system, generate_dev_scripts, generate_mono_config, - generate_watch_scripts, open_scripts, BuildSystem::Npm, + generate_watch_scripts, open_scripts, BuildSystem::{self, Npm}, }, commands::{ build_repo_list, @@ -100,27 +100,15 @@ pub fn mono_repo_mode( None }; - if build_system == Some(Npm) - && !args.build.no_watch - && generate_watch_scripts(&mono_repo_path, &repos_path, &deps, &mut ctx.io, ctx.flags)? - && args.build.watch - { - open_scripts("watch", &mono_repo_path, &mut ctx.io, ctx.flags)?; - } - - if build_system == Some(Npm) - && !args.build.no_dev - && generate_dev_scripts( - &mono_repo_path, - &repos_path, - &test_repos, - &mut ctx.io, - ctx.flags, - )? - && args.build.dev - { - open_scripts("dev", &mono_repo_path, &mut ctx.io, ctx.flags)?; - } + generate_npm_scripts( + args, + &mono_repo_path, + &repos_path, + &deps, + &test_repos, + build_system, + ctx, + )?; let paths = if ctx.flags.dry_run { SetupPaths { @@ -154,3 +142,34 @@ pub fn mono_repo_mode( } Ok(()) } + +/// Generates (and optionally opens) the npm watch/dev terminal scripts for a +/// mono workspace. No-op unless the build system is npm. +/// # Errors +/// Returns an error if a script cannot be written or a terminal cannot be opened. +fn generate_npm_scripts( + args: &ResolvedArgs, + mono_repo_path: &Path, + repos_path: &Path, + deps: &[String], + test_repos: &[String], + build_system: Option, + ctx: &mut RunCtx<'_, '_>, +) -> Result<(), String> { + if build_system != Some(Npm) { + return Ok(()); + } + if !args.build.no_watch + && generate_watch_scripts(mono_repo_path, repos_path, deps, &mut ctx.io, ctx.flags)? + && args.build.watch + { + open_scripts("watch", mono_repo_path, &mut ctx.io, ctx.flags)?; + } + if !args.build.no_dev + && generate_dev_scripts(mono_repo_path, repos_path, test_repos, &mut ctx.io, ctx.flags)? + && args.build.dev + { + open_scripts("dev", mono_repo_path, &mut ctx.io, ctx.flags)?; + } + Ok(()) +} diff --git a/src/commands/mono/setup.rs b/src/commands/mono/setup.rs index 15b62c8..63b6926 100644 --- a/src/commands/mono/setup.rs +++ b/src/commands/mono/setup.rs @@ -8,7 +8,7 @@ pub fn build_repo_list(test_repos: &[String], deps: &[String]) -> Vec { test_repos .iter() .chain(deps) + .filter(|&r| seen.insert(repo_dir_name(r))) .cloned() - .filter(|r| seen.insert(repo_dir_name(r))) .collect() }