use std::{
borrow::Cow,
collections::HashMap,
fs::{read_dir, read_to_string},
io::Write,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use clap::{Arg, ArgAction, ArgMatches, Command};
use fs_extra::dir::{CopyOptions, copy};
use rustyline::{Editor, history::DefaultHistory};
use serde_json::{Map, Value, from_str, to_string_pretty};
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
use walkdir::{DirEntry, WalkDir};
use crate::{
CliCommand,
constants::{
ERROR_FAILED_TO_EJECT_DIRECTORY_NOT_EJECTABLE, ERROR_FAILED_TO_PARSE_MANIFEST,
ERROR_FAILED_TO_PARSE_PACKAGE_JSON, ERROR_FAILED_TO_READ_MANIFEST,
ERROR_FAILED_TO_READ_PACKAGE_JSON,
},
core::{
ast::transformations::transform_domain_schemas_index::transform_domain_schemas_index_ts,
base_path::{RequiredLocation, find_app_root_path, prompt_base_path},
command::command,
manifest::{
ApplicationInitializationMetadata, InitializableManifestConfig,
InitializableManifestConfigMetadata, ManifestData,
application::ApplicationManifestData,
},
relative_path::get_relative_path,
rendered_template::{RenderedTemplate, RenderedTemplatesCache, write_rendered_templates},
validate::require_manifest,
},
prompt::{ArrayCompleter, prompt_comma_separated_list, prompt_for_confirmation},
};
#[derive(Debug)]
pub(crate) struct EjectCommand {}
impl EjectCommand {
pub(crate) fn new() -> Self {
Self {}
}
}
fn should_skip_file(path: &Path) -> bool {
path.file_name().and_then(|n| n.to_str()) == Some("index.ts")
}
fn copy_directory_selective(
source_dir: &Path,
target_dir: &Path,
dryrun: bool,
stdout: &mut StandardStream,
) -> Result<()> {
for entry in WalkDir::new(source_dir) {
let entry = entry?;
if entry.file_type().is_file() {
let source_path = entry.path();
if should_skip_file(source_path) {
continue;
}
let relative_path = source_path.strip_prefix(source_dir)?;
let target_path = target_dir.join(relative_path);
if let Some(parent) = target_path.parent() {
std::fs::create_dir_all(parent)?;
}
if dryrun {
writeln!(
stdout,
"Would copy {} to {}",
source_path.display(),
target_path.display()
)?;
} else {
std::fs::copy(source_path, target_path)?;
}
}
}
Ok(())
}
fn eject_dependencies(
project_variant: &Option<String>,
package_json: &mut Value,
matches: &ArgMatches,
line_editor: &mut Editor<ArrayCompleter, DefaultHistory>,
base_path: &Path,
stdout: &mut StandardStream,
dryrun: bool,
) -> Result<(Vec<String>, Map<String, Value>)> {
let dependencies = package_json
.get("dependencies")
.and_then(|deps| deps.as_object())
.unwrap();
let mut ejection_candidates = Vec::new();
for dependency in dependencies {
let dependency_name = dependency.0;
if (dependency_name.starts_with("@forklaunch/implementation-")
&& dependency_name.contains(project_variant.as_ref().unwrap()))
|| dependency_name.starts_with("@forklaunch/interfaces-")
|| dependency_name.starts_with("@forklaunch/infrastructure-")
{
ejection_candidates.push(dependency_name.to_string());
}
}
let dependencies_to_eject: Vec<String> = prompt_comma_separated_list(
line_editor,
"dependencies",
matches,
&ejection_candidates
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>(),
None,
"dependencies to eject",
true,
)?
.iter()
.filter(|dependency| {
if ejection_candidates.contains(dependency) {
true
} else {
let _ = stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)));
let _ = writeln!(stdout, "Ignoring {} ejection", &dependency);
let _ = stdout.reset();
false
}
})
.map(|dependency| dependency.to_string())
.collect();
detect_collisions_and_prepare_merges(&base_path, &dependencies_to_eject)?;
let filtered_dependencies: Map<String, Value> = dependencies
.iter()
.filter(|(name, _)| !dependencies_to_eject.contains(&name.to_string()))
.map(|(name, value)| (name.clone(), value.clone()))
.collect();
let node_modules_path = if base_path.join("node_modules").exists() {
base_path.join("node_modules")
} else {
base_path.parent().unwrap().join("node_modules")
};
for ejectable_dependency in &dependencies_to_eject {
let dependency_path = node_modules_path
.join(&ejectable_dependency)
.join("lib")
.join("eject");
if !dependency_path.exists() {
bail!(
"{}",
ERROR_FAILED_TO_EJECT_DIRECTORY_NOT_EJECTABLE.to_string()
);
}
let mut options = CopyOptions::new();
options.overwrite = false;
options.copy_inside = true;
for entry in read_dir(&dependency_path)? {
let entry = entry?;
let source_path = entry.path();
let target_path = base_path.join(entry.file_name());
if source_path.is_dir() {
copy_directory_selective(&source_path, &target_path, dryrun, stdout)?;
} else {
if should_skip_file(&source_path) {
if dryrun {
writeln!(
stdout,
"Would skip {} (will be merged)",
source_path.display()
)?;
}
continue;
}
if dryrun {
writeln!(
stdout,
"Would copy {} to {}",
source_path.display(),
target_path.display()
)?;
} else {
copy(&source_path, &target_path, &options).with_context(|| {
format!("Failed to copy files from {}", source_path.display())
})?;
}
}
}
}
Ok((dependencies_to_eject, filtered_dependencies))
}
fn domain_prefix(package_name: &str) -> &str {
match package_name {
"/enum" => "/domain/enum",
"/schemas" => "/domain/schemas",
"/interfaces" => "/domain/interfaces",
"/types" => "/domain/types",
_ => package_name,
}
}
fn perform_string_replacements(
app_files: &Vec<DirEntry>,
base_path: &Path,
manifest_data: &ApplicationManifestData,
dependencies_to_eject: &Vec<String>,
rendered_templates_cache: &mut RenderedTemplatesCache,
) -> Result<()> {
for file in app_files {
if file.file_name() != "package.json" {
let file_path = file.path();
let relative_path_difference = get_relative_path(
file_path
.parent()
.context("Ejected source must have a parent directory")?,
base_path,
);
let content = rendered_templates_cache
.get(file_path)?
.unwrap()
.content
.clone();
let mut new_content = content.clone();
new_content = new_content.replace(
"@{{app_name}}/core",
format!("@{}/core", manifest_data.app_name).as_str(),
);
let relative_path_difference_prefix = match relative_path_difference.to_string_lossy() {
Cow::Borrowed("") => Cow::Borrowed("."),
Cow::Borrowed(s) => Cow::Borrowed(s),
Cow::Owned(s) => Cow::Owned(s),
};
for dependency in dependencies_to_eject {
let module_types = [
"/enum",
"/schemas",
"/interfaces",
"/types",
"/services",
"/consumers",
"/producers",
];
for module_type in module_types {
new_content = new_content.replace(
format!("{}{}", dependency, module_type).as_str(),
format!(
"{}{}",
relative_path_difference_prefix,
domain_prefix(module_type)
)
.as_str(),
);
}
if new_content.contains("@forklaunch/infrastructure-")
&& dependency.contains("@forklaunch/infrastructure-")
{
let module = dependency.replace("@forklaunch/infrastructure-", "");
new_content = new_content.replace(
dependency,
format!(
"{}/infrastructure/{}",
relative_path_difference_prefix, module
)
.as_str(),
);
}
}
rendered_templates_cache.insert(
file_path.to_string_lossy(),
RenderedTemplate {
path: file_path.to_path_buf(),
content: new_content,
context: Some(format!(
"Failed to write file {}",
file_path.to_string_lossy()
)),
},
);
}
}
Ok(())
}
fn detect_collisions_and_prepare_merges(
base_path: &Path,
dependencies_to_eject: &Vec<String>,
) -> Result<(Vec<PathBuf>, Vec<PathBuf>)> {
let node_modules_path = if base_path.join("node_modules").exists() {
base_path.join("node_modules")
} else {
base_path.parent().unwrap().join("node_modules")
};
let mut collisions = Vec::new();
let mut index_ts_files = Vec::new();
let mut target_path_dependencies: HashMap<PathBuf, Vec<String>> = HashMap::new();
for ejectable_dependency in dependencies_to_eject {
let dependency_path = node_modules_path
.join(&ejectable_dependency)
.join("lib")
.join("eject");
if !dependency_path.exists() {
continue;
}
for entry in WalkDir::new(&dependency_path) {
let entry = entry?;
if entry.file_type().is_file() {
let source_path = entry.path();
let relative_path = source_path.strip_prefix(&dependency_path)?;
let target_path = base_path.join(relative_path);
target_path_dependencies
.entry(target_path)
.or_insert_with(Vec::new)
.push(ejectable_dependency.clone());
}
}
}
for (target_path, source_dependencies) in target_path_dependencies {
let target_exists = target_path.exists();
let is_index_ts = target_path.file_name().and_then(|n| n.to_str()) == Some("index.ts");
let multiple_dependencies = source_dependencies.len() > 1;
if is_index_ts {
index_ts_files.push(target_path);
} else if target_exists || multiple_dependencies {
if multiple_dependencies {
bail!(
"Multiple dependencies would create the same file: {} (from dependencies: {}). Cannot proceed with ejection.",
target_path.display(),
source_dependencies.join(", ")
);
} else {
collisions.push(target_path);
}
}
}
if !collisions.is_empty() {
let collision_list = collisions
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
bail!(
"File collisions detected (files already exist): {}. Cannot proceed with ejection.",
collision_list
);
}
Ok((collisions, index_ts_files))
}
fn merge_index_ts_files(
base_path: &Path,
dependencies_to_eject: &Vec<String>,
rendered_templates_cache: &mut RenderedTemplatesCache,
) -> Result<()> {
let node_modules_path = if base_path.join("node_modules").exists() {
base_path.join("node_modules")
} else {
base_path.parent().unwrap().join("node_modules")
};
let mut index_files_to_merge: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
for ejectable_dependency in dependencies_to_eject {
let dependency_path = node_modules_path
.join(&ejectable_dependency)
.join("lib")
.join("eject");
if !dependency_path.exists() {
continue;
}
for entry in WalkDir::new(&dependency_path) {
let entry = entry?;
if entry.file_type().is_file() {
let source_path = entry.path();
if source_path.file_name().and_then(|n| n.to_str()) == Some("index.ts") {
let relative_path = source_path.strip_prefix(&dependency_path)?;
let target_path = base_path.join(relative_path);
index_files_to_merge
.entry(target_path.clone())
.or_insert_with(Vec::new)
.push(source_path.to_path_buf());
}
}
}
}
for (target_path, source_files) in index_files_to_merge {
let mut merged_content = String::new();
if target_path.exists() {
let existing_content = rendered_templates_cache
.get(&target_path)?
.unwrap()
.content
.clone();
merged_content.push_str(&existing_content);
merged_content.push('\n');
}
for source_file in source_files {
let content = rendered_templates_cache
.get(&source_file)?
.unwrap()
.content
.clone();
merged_content.push_str(&content);
merged_content.push('\n');
}
rendered_templates_cache.insert(
target_path.clone().to_string_lossy(),
RenderedTemplate {
path: target_path.clone(),
content: merged_content,
context: Some(format!(
"Failed to write merged index.ts file {}",
target_path.display()
)),
},
);
}
Ok(())
}
fn transform_schema_index_ts(
base_path: &Path,
ejectable_dependencies: &Vec<String>,
rendered_templates_cache: &mut RenderedTemplatesCache,
) -> Result<()> {
let schema_index_ts_path = base_path.join("domain").join("schemas").join("index.ts");
let new_index_ts_text = transform_domain_schemas_index_ts(
rendered_templates_cache,
&base_path,
&ejectable_dependencies,
)?;
rendered_templates_cache.insert(
schema_index_ts_path.to_string_lossy(),
RenderedTemplate {
path: schema_index_ts_path.clone(),
content: new_index_ts_text,
context: None,
},
);
Ok(())
}
impl CliCommand for EjectCommand {
fn command(&self) -> Command {
command("eject", "Eject a forklaunch project")
.alias("ej")
.arg(
Arg::new("base_path")
.short('p')
.long("path")
.help("The application root path"),
)
.arg(
Arg::new("continue")
.short('c')
.long("continue")
.help("Continue the eject operation")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("dependencies")
.short('d')
.long("dependencies")
.help("The dependencies to eject")
.num_args(0..)
.action(ArgAction::Append),
)
.arg(
Arg::new("dryrun")
.short('n')
.long("dryrun")
.help("Dry run the application")
.action(ArgAction::SetTrue),
)
}
fn handler(&self, matches: &ArgMatches) -> Result<()> {
let _ = require_manifest(matches)?;
let mut line_editor = Editor::<ArrayCompleter, DefaultHistory>::new()?;
let mut stdout = StandardStream::stdout(ColorChoice::Always);
let continue_eject_override = matches.get_flag("continue");
let dryrun = matches.get_flag("dryrun");
if !continue_eject_override && !dryrun {
let continue_eject = prompt_for_confirmation(
&mut line_editor,
"This operation is irreversible. Do you want to continue? (y/N) ",
)?;
if !continue_eject {
log_error!(stdout, "Ejection cancelled");
return Ok(());
}
}
let (app_root_path, project_name) = find_app_root_path(matches, RequiredLocation::Project)?;
let manifest_path = app_root_path.join(".forklaunch").join("manifest.toml");
if let Some(name) = &project_name
&& crate::init::rust_project::is_rust_project(&app_root_path, name)?
{
anyhow::bail!("{}", rust_eject_message(name));
}
let mut manifest_data = toml::from_str::<ApplicationManifestData>(
&read_to_string(manifest_path).with_context(|| ERROR_FAILED_TO_READ_MANIFEST)?,
)
.with_context(|| ERROR_FAILED_TO_PARSE_MANIFEST)?;
let base_path = prompt_base_path(
&app_root_path,
&ManifestData::Application(&manifest_data),
&project_name,
&mut line_editor,
&mut stdout,
matches,
1,
)?;
manifest_data = manifest_data.initialize(InitializableManifestConfigMetadata::Application(
ApplicationInitializationMetadata {
app_name: manifest_data.app_name.clone(),
database: None,
},
));
let mut project_variant = None;
if let Some(project) = manifest_data
.projects
.iter()
.find(|project| project_name.clone().unwrap() == project.name)
{
project_variant = project.variant.clone();
}
let package_path = base_path.join("package.json");
let package_data =
read_to_string(&package_path).with_context(|| ERROR_FAILED_TO_READ_PACKAGE_JSON)?;
let mut package_json: Value =
from_str(&package_data).with_context(|| ERROR_FAILED_TO_PARSE_PACKAGE_JSON)?;
let mut rendered_templates_cache = RenderedTemplatesCache::new();
let (dependencies_to_eject, filtered_dependencies) = eject_dependencies(
&project_variant,
&mut package_json,
matches,
&mut line_editor,
&base_path,
&mut stdout,
dryrun,
)?;
let app_files = WalkDir::new(base_path.clone())
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
path.extension()
.map_or(false, |ext| ext == "ts" || ext == "tsx")
})
.collect::<Vec<_>>();
package_json["dependencies"] = filtered_dependencies.into();
if !dryrun {
perform_string_replacements(
&app_files,
&base_path,
&manifest_data,
&dependencies_to_eject,
&mut rendered_templates_cache,
)?;
merge_index_ts_files(
&base_path,
&dependencies_to_eject,
&mut rendered_templates_cache,
)?;
transform_schema_index_ts(
&base_path,
&dependencies_to_eject,
&mut rendered_templates_cache,
)?;
}
let mut templates_to_render: Vec<RenderedTemplate> = rendered_templates_cache
.drain()
.map(|(_, template)| template)
.collect();
templates_to_render.push(RenderedTemplate {
path: package_path,
content: to_string_pretty(&package_json)?,
context: None,
});
write_rendered_templates(&templates_to_render, dryrun, &mut stdout)?;
if !dryrun {
log_ok!(stdout, "Ejected successfully!");
}
Ok(())
}
}
fn rust_eject_message(name: &str) -> String {
format!(
"{name} is a Rust project; eject copies @forklaunch TypeScript package sources and does not apply. Its module HTTP code is already application source, and its framework and blueprint crates come from crates.io. To build against editable crates, check out framework-rs and run `forklaunch change application --framework-path <framework-rs>`"
)
}
#[cfg(test)]
mod rust_tests {
#[test]
fn rust_projects_are_told_why_eject_does_not_apply() {
let message = super::rust_eject_message("billing");
assert!(message.contains("Rust project") && message.contains("--framework-path"));
}
}