use std::{
collections::{HashMap, HashSet},
fs,
io::Write,
path::Path,
};
use anyhow::{Context, Result};
use clap::{Arg, ArgAction, ArgMatches, Command};
use rustyline::{Editor, history::DefaultHistory};
use serde_json::from_str as json_from_str;
use serde_yml::{from_str as yaml_from_str, to_string as yaml_to_string};
use termcolor::{Color, ColorChoice, StandardStream, WriteColor};
use toml::from_str as toml_from_str;
use crate::{
CliCommand,
constants::{
DIRS_TO_IGNORE, ERROR_FAILED_TO_PARSE_DOCKER_COMPOSE, ERROR_FAILED_TO_PARSE_MANIFEST,
InitializeType,
},
core::{
ast::infrastructure::env::find_all_env_vars,
base_path::{RequiredLocation, find_app_root_path},
command::command,
docker::{DockerCompose, sync_docker_compose_env_vars},
env_template::{generate_env_templates, sync_env_local_files},
manifest::{ProjectType, application::ApplicationManifestData},
openapi_export,
rendered_template::{RenderedTemplate, RenderedTemplatesCache, write_rendered_templates},
sync::{
artifacts::{ArtifactType, remove_project_from_artifacts},
detection::detect_project_type,
},
},
init::rust_project,
prompt::{ArrayCompleter, prompt_for_confirmation, prompt_with_validation_with_answers},
sdk::generate::generate_or_check_sdk_outputs,
};
pub fn sync_all_projects(
app_root_path: &Path,
manifest_data: &mut ApplicationManifestData,
rendered_templates_cache: &mut RenderedTemplatesCache,
confirm_all: bool,
prompts_map: &HashMap<String, HashMap<String, String>>,
stdout: &mut StandardStream,
) -> Result<bool> {
for project in manifest_data
.projects
.iter()
.filter(|project| crate::core::storefront::is_capture(project))
{
crate::core::storefront::change(app_root_path, &project.name, None, None, true)?;
}
if rust_project::is_rust_app_root(app_root_path)? {
rust_project::sync_all_rust_projects(app_root_path, stdout)?;
let updated: ApplicationManifestData = toml_from_str(&fs::read_to_string(
app_root_path.join(".forklaunch/manifest.toml"),
)?)?;
manifest_data.projects = updated.projects;
if manifest_data.runtime == "tokio" {
regenerate_native_app_sdks_after_sync(app_root_path, manifest_data, stdout)?;
return Ok(false);
}
}
let modules_path = app_root_path.join(&manifest_data.modules_path);
let mut changes_made = false;
if !modules_path.exists() {
log_warn!(
stdout,
"Modules path does not exist: {}",
modules_path.display()
);
return Ok(false);
}
log_info!(
stdout,
"Scanning modules directory: {}",
modules_path.display()
);
let existing_folders: HashSet<String> = fs::read_dir(&modules_path)?
.filter_map(|entry| {
let entry = entry.ok()?;
if entry.path().is_dir() {
entry.file_name().to_str().map(|s| s.to_string())
} else {
None
}
})
.collect();
let mut orphaned_projects = vec![];
for project in &manifest_data.projects {
if !crate::core::storefront::is_capture(project)
&& project.metadata.as_ref().and_then(|metadata| metadata.language.as_deref()) != Some("rust")
&& !DIRS_TO_IGNORE.contains(&project.name.as_str())
&& !existing_folders.contains(&project.name)
{
orphaned_projects.push(project.name.clone());
}
}
if !orphaned_projects.is_empty() {
writeln!(stdout)?;
log_warn!(
stdout,
"Found {} orphaned project(s) in manifest:",
orphaned_projects.len()
);
for project_name in &orphaned_projects {
writeln!(stdout, " - {}", project_name)?;
}
let should_cleanup = if confirm_all {
true
} else {
let mut line_editor = Editor::<ArrayCompleter, DefaultHistory>::new()?;
prompt_for_confirmation(
&mut line_editor,
"Remove these orphaned projects from all artifacts? (y/N) ",
)?
};
if should_cleanup {
for project_name in &orphaned_projects {
log_info!(stdout, "Removing '{}'...", project_name);
let project = manifest_data
.projects
.iter()
.find(|p| &p.name == project_name);
let project_type = project
.map(|p| p.r#type.clone())
.unwrap_or(ProjectType::Library);
remove_project_from_artifacts(
rendered_templates_cache,
manifest_data,
project_name,
project_type,
&[
ArtifactType::Manifest,
ArtifactType::DockerCompose,
ArtifactType::Runtime,
ArtifactType::ClientSdk,
],
app_root_path,
&modules_path,
stdout,
)?;
changes_made = true;
}
log_ok!(
stdout,
"Cleaned up {} orphaned project(s)",
orphaned_projects.len()
);
writeln!(stdout)?;
} else {
log_warn!(stdout, "Skipping cleanup of orphaned projects");
}
}
let mut confirmed_prompts = prompts_map.clone();
for entry in fs::read_dir(&modules_path)? {
let entry = entry?;
let project_path = entry.path();
if !project_path.is_dir() {
continue;
}
let Some(project_name) = project_path.file_name() else {
continue;
};
let project_name = project_name.to_string_lossy().to_string();
if DIRS_TO_IGNORE.contains(&project_name.as_str())
|| rust_project::is_rust_project(app_root_path, &project_name)?
{
continue;
}
if confirm_all {
let answers = confirmed_prompts.entry(project_name.clone()).or_default();
for key in ["override_description", "override_worker_type"] {
answers.entry(key.to_owned()).or_insert_with(|| "n".to_owned());
}
}
let prompts_map = if confirm_all { &confirmed_prompts } else { prompts_map };
writeln!(stdout)?;
log_info!(stdout, "Processing: {}", project_name);
let manifest_project = manifest_data
.projects
.iter()
.find(|p| p.name == project_name);
if manifest_project.is_some_and(crate::core::storefront::is_capture) {
continue;
}
let project_type = if let Some(existing) = manifest_project {
let init_type: InitializeType = match existing.r#type {
ProjectType::Service => InitializeType::Service,
ProjectType::Worker => InitializeType::Worker,
ProjectType::Library => InitializeType::Library,
};
log_info!(stdout, "Known as: {}", init_type.to_string());
init_type
} else {
let detected_type = detect_project_type(&project_path)?;
let project_type = if let Some(detected) = detected_type {
log_info!(stdout, "Detected as: {}", detected.to_string());
detected
} else {
log_warn!(stdout, "Could not auto-detect project type");
if confirm_all {
log_warn!(
stdout,
"Skipping '{}' (cannot auto-detect and no interaction allowed)",
project_name
);
continue;
}
const SYNC_TYPE_OPTIONS: &[&str] =
&["service", "library", "worker", "module", "router", "skip"];
let mut line_editor = Editor::<ArrayCompleter, DefaultHistory>::new()?;
let type_str = prompt_with_validation_with_answers(
&mut line_editor,
stdout,
"category",
&ArgMatches::default(),
&format!("Project type for '{}' (or 'skip' to ignore)", project_name),
Some(SYNC_TYPE_OPTIONS),
|input| SYNC_TYPE_OPTIONS.contains(&input),
|_| "Invalid option. Please try again.".to_string(),
&project_name,
prompts_map,
)?;
if type_str == "skip" {
log_info!(
stdout,
"Skipping '{}' (not a forklaunch project)",
project_name
);
continue;
}
type_str.parse()?
};
let should_sync = if confirm_all {
true
} else {
let mut line_editor = Editor::<ArrayCompleter, DefaultHistory>::new()?;
prompt_for_confirmation(
&mut line_editor,
&format!(
"Sync '{}' as {}? (y/N) ",
project_name,
project_type.to_string()
),
)?
};
if !should_sync {
log_info!(stdout, "Skipped");
continue;
}
project_type
};
let project_snapshot_before = manifest_data
.projects
.iter()
.find(|p| p.name == project_name)
.map(|p| toml::to_string(p).unwrap_or_default());
match project_type {
InitializeType::Service => {
log_info!(stdout, "Syncing service...");
match crate::sync::service::sync_service_with_cache(
&project_name,
app_root_path,
manifest_data,
&ArgMatches::default(),
prompts_map,
rendered_templates_cache,
stdout,
) {
Ok(_) => {
let project_snapshot_after = manifest_data
.projects
.iter()
.find(|p| p.name == project_name)
.map(|p| toml::to_string(p).unwrap_or_default());
if project_snapshot_before != project_snapshot_after {
changes_made = true;
}
}
Err(error) => {
return Err(error).with_context(|| format!("Failed to sync project {project_name}"));
}
}
}
InitializeType::Worker => {
log_info!(stdout, "Syncing worker...");
match crate::sync::worker::sync_worker_with_cache(
&project_name,
app_root_path,
manifest_data,
&ArgMatches::default(),
prompts_map,
rendered_templates_cache,
stdout,
) {
Ok(_) => {
let project_snapshot_after = manifest_data
.projects
.iter()
.find(|p| p.name == project_name)
.map(|p| toml::to_string(p).unwrap_or_default());
if project_snapshot_before != project_snapshot_after {
changes_made = true;
}
}
Err(error) => {
return Err(error).with_context(|| format!("Failed to sync project {project_name}"));
}
}
}
InitializeType::Library => {
log_info!(stdout, "Syncing library...");
match crate::sync::library::sync_library_with_cache(
&project_name,
app_root_path,
manifest_data,
&ArgMatches::default(),
prompts_map,
rendered_templates_cache,
stdout,
) {
Ok(_) => {
let project_snapshot_after = manifest_data
.projects
.iter()
.find(|p| p.name == project_name)
.map(|p| toml::to_string(p).unwrap_or_default());
if project_snapshot_before != project_snapshot_after {
changes_made = true;
}
}
Err(error) => {
return Err(error).with_context(|| format!("Failed to sync project {project_name}"));
}
}
}
InitializeType::Router | InitializeType::Module => {
writeln!(
stdout,
"[INFO] Skipped (routers and modules are synced as part of their parent service)"
)?;
}
}
}
Ok(changes_made)
}
fn regenerate_native_app_sdks_after_sync(
app_root_path: &Path,
manifest: &ApplicationManifestData,
stdout: &mut StandardStream,
) -> Result<()> {
let exported_services =
openapi_export::export_all_services(app_root_path, manifest, None).context(
"Project sync completed, but contract/OpenAPI export failed before SDK regeneration",
)?;
if exported_services.is_empty() {
log_info!(stdout, "No HTTP services or workers found for SDK regeneration");
return Ok(());
}
let targets = &manifest.sdk_targets;
let rust = targets.is_empty()
|| targets
.iter()
.any(|target| matches!(target.as_str(), "rust" | "all"));
let typescript = targets.is_empty()
|| targets
.iter()
.any(|target| matches!(target.as_str(), "typescript" | "ts" | "all"));
let target = match (rust, typescript) {
(true, true) => "all",
(true, false) => "rust",
_ => "typescript",
};
log_info!(
stdout,
"Regenerating {target} SDKs from {} service artifact(s)",
exported_services.len()
);
generate_or_check_sdk_outputs(app_root_path, target, "contract", None, false, stdout)
.context("Failed to regenerate SDKs from service artifacts")
}
#[derive(Debug)]
pub(crate) struct SyncAllCommand;
impl SyncAllCommand {
pub(crate) fn new() -> Self {
Self {}
}
}
impl CliCommand for SyncAllCommand {
fn command(&self) -> Command {
command(
"all",
"Sync all projects in the modules directory to application artifacts",
)
.arg(
Arg::new("base_path")
.short('p')
.long("path")
.help("The application path"),
)
.arg(
Arg::new("confirm")
.short('c')
.long("confirm")
.action(ArgAction::SetTrue)
.help("Skip confirmation prompts"),
)
.arg(
Arg::new("prompts")
.short('P')
.long("prompts")
.help("JSON object with pre-provided answers for prompts")
.value_name("JSON"),
)
}
fn handler(&self, matches: &ArgMatches) -> Result<()> {
let mut stdout = StandardStream::stdout(ColorChoice::Always);
let prompts_map: HashMap<String, HashMap<String, String>> =
if let Some(prompts_json) = matches.get_one::<String>("prompts") {
json_from_str(prompts_json).with_context(|| "Failed to parse prompts JSON")?
} else {
HashMap::new()
};
let (app_root_path, _) = find_app_root_path(matches, RequiredLocation::Application)?;
let manifest_path = app_root_path.join(".forklaunch").join("manifest.toml");
let mut rendered_templates_cache = RenderedTemplatesCache::new();
rendered_templates_cache.get(&manifest_path)?;
let manifest_template = rendered_templates_cache.get(&manifest_path)?.unwrap();
let mut manifest_data =
toml_from_str::<ApplicationManifestData>(&manifest_template.content)
.with_context(|| ERROR_FAILED_TO_PARSE_MANIFEST)?;
let confirm_all = matches.get_flag("confirm");
let _changes_made = sync_all_projects(
&app_root_path,
&mut manifest_data,
&mut rendered_templates_cache,
confirm_all,
&prompts_map,
&mut stdout,
)?;
crate::core::github_configs::reconcile_github_configs(&app_root_path)?;
if manifest_data.runtime == "tokio" {
writeln!(stdout)?;
log_header!(stdout, Color::Green, "Sync all completed");
return Ok(());
}
let modules_path = app_root_path.join(&manifest_data.modules_path);
generate_env_templates(
&modules_path,
&manifest_data,
&mut rendered_templates_cache,
&mut stdout,
)?;
sync_env_local_files(&modules_path, &manifest_data, &mut stdout)?;
sync_docker_compose_with_env_vars(
&app_root_path,
&modules_path,
&manifest_data,
&mut rendered_templates_cache,
&mut stdout,
)?;
rendered_templates_cache.insert(
manifest_path.to_string_lossy().to_string(),
RenderedTemplate {
path: manifest_path.clone(),
content: toml::to_string_pretty(&manifest_data)
.context("Failed to serialize manifest")?,
context: Some("Failed to write manifest".to_string()),
},
);
let rendered_templates: Vec<_> = rendered_templates_cache
.drain()
.map(|(_, template)| template)
.collect();
write_rendered_templates(&rendered_templates, false, &mut stdout)?;
if rust_project::is_rust_app_root(&app_root_path)? {
regenerate_native_app_sdks_after_sync(&app_root_path, &manifest_data, &mut stdout)?;
}
writeln!(stdout)?;
log_header!(stdout, Color::Green, "Sync all completed");
Ok(())
}
}
fn sync_docker_compose_with_env_vars(
app_root_path: &Path,
modules_path: &Path,
manifest_data: &ApplicationManifestData,
rendered_templates_cache: &mut RenderedTemplatesCache,
stdout: &mut StandardStream,
) -> Result<()> {
let docker_path = app_root_path.join(
manifest_data
.docker_compose_path
.clone()
.unwrap_or_else(|| "docker-compose.yaml".to_string()),
);
if !docker_path.exists() {
return Ok(());
}
let docker_content = if let Some(template) = rendered_templates_cache.get(&docker_path)? {
template.content.clone()
} else {
return Ok(());
};
let mut docker_compose: DockerCompose =
yaml_from_str(&docker_content).context(ERROR_FAILED_TO_PARSE_DOCKER_COMPOSE)?;
let env_vars_cache = RenderedTemplatesCache::new();
let project_env_vars = find_all_env_vars(modules_path, &env_vars_cache)?;
if project_env_vars.is_empty() {
return Ok(());
}
let changes_made = sync_docker_compose_env_vars(
&mut docker_compose,
&project_env_vars,
manifest_data,
modules_path,
stdout,
)?;
if changes_made {
rendered_templates_cache.insert(
docker_path.to_string_lossy().to_string(),
RenderedTemplate {
path: docker_path.clone(),
content: yaml_to_string(&docker_compose)
.context("Failed to serialize docker-compose")?,
context: Some("Failed to write docker-compose".to_string()),
},
);
log_ok!(stdout, "Synchronized docker-compose environment variables");
}
Ok(())
}